Added tag v0.2.1 for changeset 8f496a1f9b34
[notmuch] / notmuch
1 #!/usr/bin/env python
2 """This is a notmuch implementation in python. It's goal is to allow running the test suite on the cnotmuch python bindings.
3
4 This "binary" honors the NOTMUCH_CONFIG environmen variable for reading a user's
5 notmuch configuration (e.g. the database path)
6
7 This code is licensed under the GNU GPL v3+."""
8 from __future__ import with_statement # This isn't required in Python 2.6
9 import sys, os, re, logging
10 from subprocess import call
11 from cnotmuch.notmuch import Database, Query
12 PREFIX=re.compile('(\w+):(.*$)')
13 #TODO Handle variable: NOTMUCH-CONFIG
14
15 #-------------------------------------------------------------------------
16 def get_user_email_addresses():
17         """ Reads a user's notmuch config and returns his email addresses as list (name, primary_address, other_address1,...)"""
18         import email.utils
19         from ConfigParser import SafeConfigParser
20         config = SafeConfigParser()
21         conf_f = os.getenv('NOTMUCH_CONFIG',
22                            os.path.expanduser('~/.notmuch-config'))
23         config.read(conf_f)
24         if not config.has_option('user','name'): name = ""
25         else:name = config.get('user','name')
26
27         if not config.has_option('user','primary_email'): mail = ""
28         else:mail = config.get('user','primary_email')
29
30         if not config.has_option('user','other_email'): other = []
31         else:other = config.get('user','other_email').rstrip(';').split(';')
32
33         other.insert(0, mail)
34         other.insert(0, name)
35         return other
36 #-------------------------------------------------------------------------
37 HELPTEXT="""The notmuch mail system.
38
39 Usage: notmuch <command> [args...]
40
41 Where <command> and [args...] are as follows:
42
43         setup   Interactively setup notmuch for first use.
44
45         new     [--verbose]
46
47                 Find and import new messages to the notmuch database.
48
49         search  [options...] <search-terms> [...]
50
51                 Search for messages matching the given search terms.
52
53         show    <search-terms> [...]
54
55                 Show all messages matching the search terms.
56
57         count   <search-terms> [...]
58
59                 Count messages matching the search terms.
60
61         reply   [options...] <search-terms> [...]
62
63                 Construct a reply template for a set of messages.
64
65         tag     +<tag>|-<tag> [...] [--] <search-terms> [...]
66
67                 Add/remove tags for all messages matching the search terms.
68
69         dump    [<filename>]
70
71                 Create a plain-text dump of the tags for each message.
72
73         restore <filename>
74
75                 Restore the tags from the given dump file (see 'dump').
76
77         search-tags     [<search-terms> [...] ]
78
79                 List all tags found in the database or matching messages.
80
81         help    [<command>]
82
83                 This message, or more detailed help for the named command.
84
85 Use "notmuch help <command>" for more details on each command.
86 And "notmuch help search-terms" for the common search-terms syntax.
87 """
88 #-------------------------------------------------------------------------
89 #TODO: replace the dynamic pieces
90 USAGE="""Notmuch is configured and appears to have a database. Excellent!
91
92 At this point you can start exploring the functionality of notmuch by
93 using commands such as:
94
95         notmuch search tag:inbox
96
97         notmuch search to:"Sebastian Spaeth"
98
99         notmuch search from:"Sebastian@SSpaeth.de"
100
101         notmuch search subject:"my favorite things"
102
103 See "notmuch help search" for more details.
104
105 You can also use "notmuch show" with any of the thread IDs resulting
106 from a search. Finally, you may want to explore using a more sophisticated
107 interface to notmuch such as the emacs interface implemented in notmuch.el
108 or any other interface described at http://notmuchmail.org
109
110 And don't forget to run "notmuch new" whenever new mail arrives.
111
112 Have fun, and may your inbox never have much mail.
113 """
114 #-------------------------------------------------------------------------
115 def quote_reply(oldbody ,date, from_address):
116    """Transform a mail body into a quote text starting with On blah, x wrote:
117    :param body: a str with a mail body
118    :returns: The new payload of the email.message()
119    """
120    from cStringIO import StringIO
121
122    #we get handed a string, wrap it in a file-like object
123    oldbody = StringIO(oldbody)
124    newbody = StringIO()
125
126    newbody.write("On %s, %s wrote:\n" % (date, from_address))
127
128    for line in oldbody:
129       newbody.write("> " + line)
130
131    return newbody.getvalue()
132     
133 def format_reply(msgs):
134    """Gets handed Messages() and displays the reply to them"""
135    import email
136
137    for msg in msgs:
138       f = open(msg.get_filename(),"r")
139       reply = email.message_from_file(f)
140
141       #handle the easy non-multipart case:
142       if not reply.is_multipart():
143          reply.set_payload(quote_reply(reply.get_payload(),
144                                        reply['date'],reply['from']))
145       else:
146          #handle the tricky multipart case
147          deleted = ""
148          """A string describing which nontext attachements have been deleted"""
149          delpayloads = []
150          """A list of payload indices to be deleted"""
151
152          payloads = reply.get_payload()
153
154          for i, part in enumerate(payloads):
155
156             mime_main = part.get_content_maintype()
157             if mime_main not in ['multipart', 'message', 'text']:
158                deleted += "Non-text part: %s\n" % (part.get_content_type())
159                payloads[i].set_payload("Non-text part: %s" % (part.get_content_type()))
160                payloads[i].set_type('text/plain')
161                delpayloads.append(i)
162             else:
163                # payloads[i].set_payload("Text part: %s" % (part.get_content_type()))
164                payloads[i].set_payload(quote_reply(payloads[i].get_payload(),reply['date'],reply['from']))
165
166
167          # Delete those payloads that we don't need anymore
168          for i in reversed(sorted(delpayloads)):
169             del payloads[i]
170
171       #Back to single- and multipart handling
172
173       my_addresses = get_user_email_addresses()
174       used_address = None
175       # filter our email addresses from all to: cc: and bcc: fields
176       # if we find one of "my" addresses being used, 
177       # it is stored in used_address
178       for header in ['To', 'CC', 'Bcc']:
179          if not header in reply:
180             #only handle fields that exist
181             continue
182          addresses = email.utils.getaddresses(reply.get_all(header,[]))
183          purged_addr = []
184          for name, mail in addresses:
185             if mail in my_addresses[1:]:
186                used_address = email.utils.formataddr((my_addresses[0],mail))
187             else:
188                purged_addr.append(email.utils.formataddr((name,mail)))
189
190          if len(purged_addr):
191             reply.replace_header(header, ", ".join(purged_addr))
192          else: 
193             #we deleted all addresses, delete the header
194             del reply[header]
195
196       # Use our primary email address to the From
197       # (save original from line, we still need it)
198       orig_from = reply['From']
199       del reply['From']
200       reply['From'] = used_address if used_address \
201           else email.utils.formataddr((my_addresses[0],my_addresses[1]))
202       
203       #reinsert the Subject after the From
204       orig_subject = reply['Subject']
205       del reply['Subject']
206       reply['Subject'] = 'Re: ' + orig_subject
207
208       # Calculate our new To: field
209       new_to = orig_from
210       # add all remaining original 'To' addresses
211       if 'To' in reply:
212          new_to += ", " + reply['To']
213       del reply['To']
214       reply.add_header('To', new_to)
215
216       # Add our primary email address to the BCC
217       new_bcc = my_addresses[1]
218       if reply.has_key('Bcc'):
219          new_bcc += ', '  + reply['Bcc']
220          del reply['Bcc']
221       reply['Bcc'] = new_bcc
222
223       # Set replies 'In-Reply-To' header to original's Message-ID
224       if reply.has_key('Message-ID') :
225          del reply['In-Reply-To']
226          reply['In-Reply-To'] = reply['Message-ID']
227
228       #Add original's Message-ID to replies 'References' header.
229       if reply.has_key('References'):
230          ref = reply['References'] + ' ' +reply['Message-ID']
231       else:
232          ref = reply['Message-ID']
233       del reply['References']
234       reply['References'] = ref
235       
236       # Delete the original Message-ID.
237       del(reply['Message-ID'])
238
239       # filter all existing headers but a few and delete them from 'reply'
240       delheaders = filter(lambda x: x not in ['From','To','Subject','CC','Bcc',
241                                               'In-Reply-To', 'References',
242                                               'Content-Type'],reply.keys())
243       map(reply.__delitem__, delheaders)
244
245       """
246 From: Sebastian Spaeth <Sebastian@SSpaeth.de>
247 Subject: Re: Template =?iso-8859-1?b?Zvxy?= das Kochrezept
248 In-Reply-To: <4A6D55F9.6040405@SSpaeth.de>
249 References:  <4A6D55F9.6040405@SSpaeth.de>
250       """
251    #return without Unixfrom
252    return reply.as_string(False)
253 #-------------------------------------------------------------------------
254 def quote_query_line(argv):
255    #mangle arguments wrapping terms with spaces in quotes
256    for i in xrange(0,len(argv)):
257       if argv[i].find(' ') >= 0:
258          #if we use prefix:termWithSpaces, put quotes around term
259          m = PREFIX.match(argv[i])
260          if m:
261             argv[i] = '%s:"%s"' % (m.group(1), m.group(2))
262          else:
263             argv[i] = '"'+argv[i]+'"'
264    return ' '.join(argv)
265
266 if __name__ == '__main__':
267
268    # Handle command line options
269    #-------------------------------------
270    # No option given, print USAGE and exit
271    if len(sys.argv) == 1:
272       print USAGE
273    #-------------------------------------
274    elif sys.argv[1] == 'setup':
275        """Interactively setup notmuch for first use."""
276        print "Not implemented."
277    #-------------------------------------
278    elif sys.argv[1] == 'new':
279        """ Interactively setup notmuch for first use. """
280        #print "Not implemented. We cheat by calling the proper notmuch"
281        call(['notmuch new'],shell=True)
282    #-------------------------------------
283    elif sys.argv[1] == 'help':
284        if len(sys.argv) == 2: print HELPTEXT
285        else: print "Not implemented"
286    #-------------------------------------
287    elif sys.argv[1] == 'search':
288       db = Database()
289       if len(sys.argv) == 2:
290          #no further search term
291          querystr=''
292       else:
293          #mangle arguments wrapping terms with spaces in quotes
294          querystr = quote_query_line(sys.argv[2:])
295       logging.debug("search "+querystr)
296       t = Query(db,querystr).search_threads()
297       for thread in t:
298          print(str(thread))
299    #-------------------------------------
300    elif sys.argv[1] == 'show':
301       db = Database()
302       if len(sys.argv) == 2:
303          #no further search term
304          querystr=''
305       else:
306          #mangle arguments wrapping terms with spaces in quotes
307          querystr = quote_query_line(sys.argv[2:])
308       logging.debug("show "+querystr)
309       m = Query(db,querystr).search_messages()
310       for msg in m:
311          print(msg.format_as_text())
312
313    #-------------------------------------
314    elif sys.argv[1] == 'reply':
315       db = Database()
316       if len(sys.argv) == 2:
317          #no search term. abort
318          print("Error: notmuch reply requires at least one search term.")
319          sys.exit()
320
321       #mangle arguments wrapping terms with spaces in quotes
322       querystr = quote_query_line(sys.argv[2:])
323       logging.debug("reply "+querystr)
324       msgs = Query(db,querystr).search_messages()
325       print (format_reply(msgs))
326
327    #-------------------------------------
328    elif sys.argv[1] == 'count':
329       if len(sys.argv) == 2:
330          #no further search term, count all
331          querystr=''
332       else:
333          #mangle arguments wrapping terms with spaces in quotes
334          querystr = quote_query_line(sys.argv[2:])
335       print(Database().create_query(querystr).count_messages())
336       
337    #-------------------------------------
338    elif sys.argv[1] == 'tag':
339       #build lists of tags to be added and removed
340       add, remove = [], []
341       while not sys.argv[2]=='--' and \
342             (sys.argv[2].startswith('+') or sys.argv[2].startswith('-')):
343          if sys.argv[2].startswith('+'):
344             #append to add list without initial +
345             add.append(sys.argv.pop(2)[1:])
346          else:
347             #append to remove list without initial -
348             remove.append(sys.argv.pop(2)[1:])
349       #skip eventual '--'
350       if sys.argv[2]=='--': sys.argv.pop(2)
351       #the rest is search terms
352       querystr = quote_query_line(sys.argv[2:])
353       logging.debug("tag search-term "+querystr)
354       db = Database(mode=Database.MODE.READ_WRITE)
355       m  = Query(db,querystr).search_messages()
356       for msg in m:
357          #actually add and remove all tags
358          map(msg.add_tag, add)
359          map(msg.remove_tag, remove)
360    #-------------------------------------
361    elif sys.argv[1] == 'search-tags':
362       if len(sys.argv) == 2:
363          #no further search term
364          print("\n".join(Database().get_all_tags()))
365       else:
366          #mangle arguments wrapping terms with spaces in quotes
367          querystr = quote_query_line(sys.argv[2:])
368          logging.debug("search-term "+querystr)
369          db = Database()
370          m  = Query(db,querystr).search_messages()
371          print("\n".join([t for t in m.collect_tags()]))
372    #-------------------------------------
373    elif sys.argv[1] == 'dump':
374       #TODO: implement "dump <filename>"
375       if len(sys.argv) == 2:
376          f = sys.stdout
377       else:
378          f = open(sys.argv[2],"w")
379       db = Database()
380       q = Query(db,'')
381       q.set_sort(Query.SORT.MESSAGE_ID)
382       m = q.search_messages()
383       for msg in m:
384          f.write("%s (%s)\n" % (msg.get_message_id(), msg.get_tags()))
385    #-------------------------------------
386    elif sys.argv[1] == 'restore':
387       import re
388       if len(sys.argv) == 2:
389          print("No filename given. Reading dump from stdin.")
390          f = sys.stdin
391       else:
392          f = open(sys.argv[2],"r")
393       #split the msg id and the tags
394       MSGID_TAGS = re.compile("(\S+)\s\((.*)\)$")
395       db = Database(mode=Database.MODE.READ_WRITE)
396
397       #read each line of the dump file
398       for line in f:
399          m = MSGID_TAGS.match(line)
400          if not m:
401             sys.stderr.write("Warning: Ignoring invalid input line: %s" % 
402                              line)
403             continue
404          # split line in components and fetch message
405          msg_id = m.group(1)
406          new_tags= set(m.group(2).split())
407          msg    = db.find_message(msg_id)
408
409          if msg == None:
410             sys.stderr.write(
411                "Warning: Cannot apply tags to missing message: %s\n" % id)
412             continue
413
414          #do nothing if the old set of tags is the same as the new one
415          old_tags = set(msg.get_tags())
416          if old_tags == new_tags: continue
417
418          #set the new tags
419          msg.freeze()
420          #only remove tags if the new ones are not a superset anyway
421          if not (new_tags > old_tags): msg.remove_all_tags()
422          for tag in new_tags: msg.add_tag(tag)
423          msg.thaw()
424             
425    #-------------------------------------
426    else:
427        # unknown command
428        print "Error: Unknown command '%s' (see \"notmuch help\")" % sys.argv[1]
429
430
431    #TODO: implement
432    """
433 setup
434 new
435 show    <search-terms> [...]
436 reply   [options...] <search-terms> [...]
437 restore <filename>
438    """