Extent pypi documentation. Set version to 0.2.2
[notmuch] / notmuch
1 #!/usr/bin/env python
2 """This is a notmuch implementation in python. 
3 It's goal is to allow running the test suite on the cnotmuch python bindings.
4
5 This "binary" honors the NOTMUCH_CONFIG environmen variable for reading a user's
6 notmuch configuration (e.g. the database path).
7
8    (c) 2010 by Sebastian Spaeth <Sebastian@SSpaeth.de>
9                Jesse Rosenthal <jrosenthal@jhu.edu>
10    This code is licensed under the GNU GPL v3+.
11 """
12 from __future__ import with_statement # This isn't required in Python 2.6
13 import sys, os, re, stat
14 from cnotmuch.notmuch import Database, Query, NotmuchError, STATUS
15 PREFIX=re.compile('(\w+):(.*$)')
16 #-------------------------------------------------------------------------
17 def quote_query_line(argv):
18    #mangle arguments wrapping terms with spaces in quotes
19    for i in xrange(0,len(argv)):
20       if argv[i].find(' ') >= 0:
21          #if we use prefix:termWithSpaces, put quotes around term
22          m = PREFIX.match(argv[i])
23          if m:
24             argv[i] = '%s:"%s"' % (m.group(1), m.group(2))
25          else:
26             argv[i] = '"'+argv[i]+'"'
27    return ' '.join(argv)
28
29 #-------------------------------------------------------------------------
30 class Notmuch:
31
32     def __init__(self):
33         self._config = None
34
35     def cmd_usage(self):
36        """Print the usage text and exits"""
37        data={}
38        names = self.get_user_email_addresses()
39        data['fullname']   =names[0] if names[0] else 'My Name'
40        data['mailaddress']=names[1] if names[1] else 'My@email.address'
41        print (Notmuch.USAGE % data)
42
43     def cmd_new(self):
44         """Run 'notmuch new'"""
45         #get the database directory
46         db = Database(mode=Database.MODE.READ_WRITE)
47         path = db.get_path()
48
49         (added, moved, removed) = self._add_new_files_recursively(path, db)
50         print (added, moved, removed)
51
52     def cmd_help(self, subcmd=None):
53        """Print help text for 'notmuch help'"""
54        if len(subcmd) > 1:
55           print "Help for specific commands not implemented"
56           return
57
58        print (Notmuch.HELPTEXT)
59
60     def _get_user_notmuch_config(self):
61         """Returns the ConfigParser of the user's notmuch-config"""
62         # return the cached config parser if we read it already
63         if self._config is not None:
64             return self._config
65
66         from ConfigParser import SafeConfigParser
67         config = SafeConfigParser()
68         conf_f = os.getenv('NOTMUCH_CONFIG',
69                            os.path.expanduser('~/.notmuch-config'))
70         config.read(conf_f)
71         self._config = config
72         return config
73
74     def _add_new_files_recursively(self, path, db):
75         """:returns: (added, moved, removed)"""
76         print "Enter add new files with path %s" % path
77         (added, moved, removed) = (0,)*3
78
79         try:
80            #get the Directory() object for this path
81            db_dir = db.get_directory(path)
82         except NotmuchError:
83            #Occurs if we have wrong absolute paths in the db, for example
84            return (0,0,0)
85
86
87         #for folder in subdirs:
88         
89         #TODO, retrieve dir mtime here and store it later
90         #as long as Filenames() does not allow multiple iteration, we need to
91         #use this kludgy way to get a sorted list of filenames
92         #db_files is a list of subdirectories and filenames in this folder
93         db_files = set()
94         db_folders = set()
95         for subdir in db_dir.get_child_directories():
96            db_folders.add(subdir)
97         for file in db_dir.get_child_files():
98            db_files.add(file)
99
100         fs_files = set(os.listdir(db_dir.path))
101
102         #list of files (and folders) on the fs, but not the db
103         for fs_file in ((fs_files - db_files) - db_folders):
104            absfile = os.path.normpath(os.path.join(db_dir.path, fs_file))
105            statinfo = os.stat(absfile)
106
107            if stat.S_ISDIR(statinfo.st_mode):
108               #This is a directory
109               if fs_file in ['.notmuch','tmp','.']:
110                  continue
111               print "%s %s" % (fs_file, db_folders)
112               print "Directory not in db yet. Descending into %s" % absfile
113               (new_added, new_moved, new_removed) = \
114                   self._add_new_files_recursively(absfile, db)
115               added += new_added
116               moved += new_moved
117               removed += new_removed
118
119            elif stat.S_ISLNK(statinfo.st_mode):
120               print ("%s is a symbolic link (%d). FIXME!!!" % (absfile, statinfo.st_mode))
121               sys.exit()
122            else:
123               #This is a regular file, not in the db yet. Add it.
124               print "This file needs to be added %s" % (absfile)
125               (msg, status) = db.add_message(absfile)
126               # We increases 'added', even on dupe messages. If it is a moved
127               # message, we will deduct it later and increase 'moved' instead
128               added += 1
129
130               if status == STATUS.DUPLICATE_MESSAGE_ID:
131                  #This message was already in the database
132                  print "Added msg was in the db"
133               else:
134                  print "New message."
135
136         # Finally a list of files (not folders) in the database,
137         # but not the filesystem
138         for db_file in (db_files - fs_files):
139            absfile = os.path.normpath(os.path.join(db_dir.path, db_file))
140
141            #remove a mail message from the db
142            print ("%s is not on the fs anymore. Delete" % absfile)
143            status = db.remove_message(absfile)
144            if status == STATUS.SUCCESS:
145               # we just deleted the last reference, so this was a remove
146               removed += 1
147               sys.stderr.write("SUCCESS %d %s %s.\n" % (status, STATUS.status2str(status), absfile))
148            elif status == STATUS.DUPLICATE_MESSAGE_ID:
149               # The filename exists already somewhere else, so this is a move
150               moved += 1
151               added -= 1
152               sys.stderr.write("DUPE %d %s %s.\n" % (status, STATUS.status2str(status), absfile))
153            else: 
154               #This should not occur
155               sys.stderr.write("This should not occur %d %s %s.\n" % (status, STATUS.status2str(status), absfile))
156
157         #list of folders in the filesystem. Just descend into dirs
158         for fs_file in fs_files:
159            absfile = os.path.normpath(os.path.join(db_dir.path, fs_file))
160            if os.path.isdir(absfile):
161               #This is a directory.
162               # Remove it from the db_folder list. All remaining db_folders
163               # at the end will be not present on the file system.
164               db_folders.remove(fs_file)
165               if fs_file in ['.notmuch','tmp','.']:
166                  continue
167               (new_added, new_moved, new_removed) = \
168                   self._add_new_files_recursively(absfile, db)
169               added += new_added
170               moved += new_moved
171               removed += new_removed
172
173            # we are not interested in anything but directories here
174            #TODO: All remaining elements of db_folders are not in the filesystem
175            #delete those
176
177         return (added, moved, removed)
178         #Read the mtime of a directory from the filesystem
179         #
180         #* Call :meth:`Database.add_message` for all mail files in
181         #  the directory
182
183         #* Call notmuch_directory_set_mtime with the mtime read from the 
184         #  filesystem.  Then, when wanting to check for updates to the
185         #  directory in the future, the client can call :meth:`get_mtime`
186         #  and know that it only needs to add files if the mtime of the 
187         #  directory and files are newer than the stored timestamp.
188
189     def get_user_email_addresses(self):
190         """ Reads a user's notmuch config and returns his email addresses as
191         list (name, primary_address, other_address1,...)"""
192         import email.utils
193
194         #read the config file
195         config = self._get_user_notmuch_config()
196
197         if not config.has_option('user','name'): name = ""
198         else:name = config.get('user','name')
199
200         if not config.has_option('user','primary_email'): mail = ""
201         else:mail = config.get('user','primary_email')
202
203         if not config.has_option('user','other_email'): other = []
204         else:other = config.get('user','other_email').rstrip(';').split(';')
205
206         other.insert(0, mail)
207         other.insert(0, name)
208         return other
209
210     def quote_msg_body(self, oldbody ,date, from_address):
211         """Transform a mail body into a quoted text, 
212         starting with On blah, x wrote:
213
214         :param body: a str with a mail body
215         :returns: The new payload of the email.message()
216         """
217         from cStringIO import StringIO
218
219         #we get handed a string, wrap it in a file-like object
220         oldbody = StringIO(oldbody)
221         newbody = StringIO()
222
223         newbody.write("On %s, %s wrote:\n" % (date, from_address))
224
225         for line in oldbody:
226            newbody.write("> " + line)
227
228         return newbody.getvalue()
229
230     def format_reply(self, msgs):
231         """Gets handed Messages() and displays the reply to them
232
233         This is pretty ugly and hacky. It tries to mimic the "real"
234         notmuch output as much as it can to pass the test suite. It
235         could deserve a healthy bit of love.  It is also buggy because
236         it returns after the first message it has handled."""
237         import email
238
239         for msg in msgs:
240            f = open(msg.get_filename(),"r")
241            reply = email.message_from_file(f)
242
243            #handle the easy non-multipart case:
244            if not reply.is_multipart():
245               reply.set_payload(self.quote_msg_body(reply.get_payload(),
246                                                    reply['date'],reply['from']))
247            else:
248               #handle the tricky multipart case
249               deleted = ""
250               """A string describing which nontext attachements that
251                  have been deleted"""
252               delpayloads = []
253               """A list of payload indices to be deleted"""
254
255               payloads = reply.get_payload()
256
257               for i, part in enumerate(payloads):
258
259                  mime_main = part.get_content_maintype()
260                  if mime_main not in ['multipart', 'message', 'text']:
261                     deleted += "Non-text part: %s\n" % (part.get_content_type())
262                     payloads[i].set_payload("Non-text part: %s" % (part.get_content_type()))
263                     payloads[i].set_type('text/plain')
264                     delpayloads.append(i)
265                  elif mime_main == 'text':
266                     payloads[i].set_payload(self.quote_msg_body(payloads[i].get_payload(),reply['date'],reply['from']))
267                  else:
268                     #TODO handle deeply nested multipart messages
269                     sys.stderr.write ("FIXME: Ignoring multipart part. Handle me\n")
270
271               # Delete those payloads that we don't need anymore
272               for i in reversed(sorted(delpayloads)):
273                  del payloads[i]
274
275         #Back to single- and multipart handling
276         my_addresses = self.get_user_email_addresses()
277         used_address = None
278         # filter our email addresses from all to: cc: and bcc: fields
279         # if we find one of "my" addresses being used, 
280         # it is stored in used_address
281         for header in ['To', 'CC', 'Bcc']:
282            if not header in reply:
283               #only handle fields that exist
284               continue
285            addresses = email.utils.getaddresses(reply.get_all(header,[]))
286            purged_addr = []
287            for name, mail in addresses:
288               if mail in my_addresses[1:]:
289                  used_address = email.utils.formataddr((my_addresses[0],mail))
290               else:
291                  purged_addr.append(email.utils.formataddr((name,mail)))
292
293            if len(purged_addr):
294               reply.replace_header(header, ", ".join(purged_addr))
295            else: 
296               #we deleted all addresses, delete the header
297               del reply[header]
298
299         # Use our primary email address to the From
300         # (save original from line, we still need it)
301         orig_from = reply['From']
302         del reply['From']
303         reply['From'] = used_address if used_address \
304             else email.utils.formataddr((my_addresses[0],my_addresses[1]))
305       
306         #reinsert the Subject after the From
307         orig_subject = reply['Subject']
308         del reply['Subject']
309         reply['Subject'] = 'Re: ' + orig_subject
310
311         # Calculate our new To: field
312         new_to = orig_from
313         # add all remaining original 'To' addresses
314         if 'To' in reply:
315            new_to += ", " + reply['To']
316         del reply['To']
317         reply.add_header('To', new_to)
318
319         # Add our primary email address to the BCC
320         new_bcc = my_addresses[1]
321         if reply.has_key('Bcc'):
322            new_bcc += ', '  + reply['Bcc']
323            del reply['Bcc']
324         reply['Bcc'] = new_bcc
325
326         # Set replies 'In-Reply-To' header to original's Message-ID
327         if reply.has_key('Message-ID') :
328            del reply['In-Reply-To']
329            reply['In-Reply-To'] = reply['Message-ID']
330
331         #Add original's Message-ID to replies 'References' header.
332         if reply.has_key('References'):
333            ref = reply['References'] + ' ' +reply['Message-ID']
334         else:
335            ref = reply['Message-ID']
336         del reply['References']
337         reply['References'] = ref
338       
339         # Delete the original Message-ID.
340         del(reply['Message-ID'])
341
342         # filter all existing headers but a few and delete them from 'reply'
343         delheaders = filter(lambda x: x not in ['From','To','Subject','CC',
344                                                 'Bcc','In-Reply-To',
345                                                 'References','Content-Type'],
346                             reply.keys())
347         map(reply.__delitem__, delheaders)
348
349         # TODO: OUCH, we return after the first msg we have handled rather than
350         # handle all of them
351         #return resulting message without Unixfrom
352         return reply.as_string(False)
353
354
355     HELPTEXT="""The notmuch mail system.
356
357 Usage: notmuch <command> [args...]
358
359 Where <command> and [args...] are as follows:
360
361         setup   Interactively setup notmuch for first use.
362
363         new     [--verbose]
364
365                 Find and import new messages to the notmuch database.
366
367         search  [options...] <search-terms> [...]
368
369                 Search for messages matching the given search terms.
370
371         show    <search-terms> [...]
372
373                 Show all messages matching the search terms.
374
375         count   <search-terms> [...]
376
377                 Count messages matching the search terms.
378
379         reply   [options...] <search-terms> [...]
380
381                 Construct a reply template for a set of messages.
382
383         tag     +<tag>|-<tag> [...] [--] <search-terms> [...]
384
385                 Add/remove tags for all messages matching the search terms.
386
387         dump    [<filename>]
388
389                 Create a plain-text dump of the tags for each message.
390
391         restore <filename>
392
393                 Restore the tags from the given dump file (see 'dump').
394
395         search-tags     [<search-terms> [...] ]
396
397                 List all tags found in the database or matching messages.
398
399         help    [<command>]
400
401                 This message, or more detailed help for the named command.
402
403 Use "notmuch help <command>" for more details on each command.
404 And "notmuch help search-terms" for the common search-terms syntax.
405 """
406
407     USAGE="""Notmuch is configured and appears to have a database. Excellent!
408
409 At this point you can start exploring the functionality of notmuch by
410 using commands such as:
411
412         notmuch search tag:inbox
413
414         notmuch search to:"%(fullname)s"
415
416         notmuch search from:"%(mailaddress)s"
417
418         notmuch search subject:"my favorite things"
419
420 See "notmuch help search" for more details.
421
422 You can also use "notmuch show" with any of the thread IDs resulting
423 from a search. Finally, you may want to explore using a more sophisticated
424 interface to notmuch such as the emacs interface implemented in notmuch.el
425 or any other interface described at http://notmuchmail.org
426
427 And don't forget to run "notmuch new" whenever new mail arrives.
428
429 Have fun, and may your inbox never have much mail.
430 """
431
432 # MAIN
433 #-------------------------------------------------------------------------
434 if __name__ == '__main__':
435
436    # Handle command line options
437    #-------------------------------------
438    # No option given, print USAGE and exit
439    if len(sys.argv) == 1:
440       Notmuch().cmd_usage()
441    #-------------------------------------
442    elif sys.argv[1] == 'setup':
443        """Interactively setup notmuch for first use."""
444        print "Not implemented."
445    #-------------------------------------
446    elif sys.argv[1] == 'new':
447        """Check for new and removed messages."""
448        Notmuch().cmd_new()
449    #-------------------------------------
450    elif sys.argv[1] == 'help':
451       """Print the help text"""
452       Notmuch().cmd_help(sys.argv[1:])
453    #-------------------------------------
454    elif sys.argv[1] == 'part':
455       db = Database()
456       query_string = ''
457       part_num=0
458       first_search_term = None
459       for (i, arg) in enumerate(sys.argv[1:]):
460           if arg.startswith('--part='):
461              part_num_str=arg.split("=")[1]
462              try:
463                 part_num = int(part_num_str)
464              except ValueError:
465                 # just emulating behavior
466                 sys.exit()
467           elif not arg.startswith('--'):
468               #save the position of the first sys.argv that is a search term
469               first_search_term = i+1
470
471       if first_search_term:
472           #mangle arguments wrapping terms with spaces in quotes
473           querystr = quote_query_line(sys.argv[first_search_term:])
474       
475       qry = Query(db,querystr)
476       msgs = qry.search_messages()
477       msg_list = []
478       for m in msgs:
479          msg_list.append(m)
480          
481       if len(msg_list) == 0:
482          sys.exit()
483       elif len(msg_list) > 1:
484          raise Exception("search term did not match precisely one message")
485       else:
486          msg = msg_list[0]
487          print(msg.get_part(part_num))
488    #-------------------------------------
489    elif sys.argv[1] == 'search':
490       db = Database()
491       query_string = ''
492       sort_order="newest-first"
493       first_search_term = None
494       for (i, arg) in enumerate(sys.argv[1:]):
495           if arg.startswith('--sort='):
496              sort_order=arg.split("=")[1]
497              if not sort_order in ("oldest-first", "newest-first"):
498                 raise Exception("unknown sort order")
499           elif not arg.startswith('--'):
500               #save the position of the first sys.argv that is a search term
501               first_search_term = i+1
502
503       if first_search_term:
504           #mangle arguments wrapping terms with spaces in quotes
505           querystr = quote_query_line(sys.argv[first_search_term:])
506
507       qry = Query(db,querystr)
508       if sort_order == "oldest-first":
509          qry.set_sort(Query.SORT.OLDEST_FIRST)
510       else:
511          qry.set_sort(Query.SORT.NEWEST_FIRST)
512       t = qry.search_threads()
513
514       for thread in t:
515          print(str(thread))
516
517    #-------------------------------------
518    elif sys.argv[1] == 'show':
519       entire_thread = False
520       db = Database()
521       out_format="text"
522       querystr=''
523       first_search_term = None
524
525       #ugly homegrown option parsing
526       #TODO: use OptionParser
527       for (i, arg) in enumerate(sys.argv[1:]):
528           if arg == '--entire-thread':
529               entire_thread = True
530           elif arg.startswith("--format="):
531               out_format = arg.split("=")[1]
532               if out_format == 'json':
533                   #for compatibility use --entire-thread for json
534                   entire_thread = True
535               if not out_format in ("json", "text"):
536                   raise Exception("unknown format")
537           elif not arg.startswith('--'):
538               #save the position of the first sys.argv that is a search term
539               first_search_term = i+1
540
541       if first_search_term:
542           #mangle arguments wrapping terms with spaces in quotes
543           querystr = quote_query_line(sys.argv[first_search_term:])
544
545       t = Query(db,querystr).search_threads()
546
547       first_toplevel=True
548       if out_format.lower()=="json":
549          sys.stdout.write("[")
550
551       for thrd in t:
552          msgs = thrd.get_toplevel_messages()
553
554          if not first_toplevel:
555             if out_format.lower()=="json":
556                sys.stdout.write(", ")
557
558          first_toplevel = False
559
560          msgs.print_messages(out_format, 0, entire_thread)
561
562       if out_format.lower() == "json":
563          sys.stdout.write("]")
564       sys.stdout.write("\n")
565
566    #-------------------------------------
567    elif sys.argv[1] == 'reply':
568       db = Database()
569       if len(sys.argv) == 2:
570          #no search term. abort
571          print("Error: notmuch reply requires at least one search term.")
572          sys.exit()
573
574       #mangle arguments wrapping terms with spaces in quotes
575       querystr = quote_query_line(sys.argv[2:])
576       msgs = Query(db,querystr).search_messages()
577       print (Notmuch().format_reply(msgs))
578
579    #-------------------------------------
580    elif sys.argv[1] == 'count':
581       if len(sys.argv) == 2:
582          #no further search term, count all
583          querystr=''
584       else:
585          #mangle arguments wrapping terms with spaces in quotes
586          querystr = quote_query_line(sys.argv[2:])
587       print(Database().create_query(querystr).count_messages())
588       
589    #-------------------------------------
590    elif sys.argv[1] == 'tag':
591       #build lists of tags to be added and removed
592       add, remove = [], []
593       while not sys.argv[2]=='--' and \
594             (sys.argv[2].startswith('+') or sys.argv[2].startswith('-')):
595          if sys.argv[2].startswith('+'):
596             #append to add list without initial +
597             add.append(sys.argv.pop(2)[1:])
598          else:
599             #append to remove list without initial -
600             remove.append(sys.argv.pop(2)[1:])
601       #skip eventual '--'
602       if sys.argv[2]=='--': sys.argv.pop(2)
603       #the rest is search terms
604       querystr = quote_query_line(sys.argv[2:])
605       db = Database(mode=Database.MODE.READ_WRITE)
606       m  = Query(db,querystr).search_messages()
607       for msg in m:
608          #actually add and remove all tags
609          map(msg.add_tag, add)
610          map(msg.remove_tag, remove)
611    #-------------------------------------
612    elif sys.argv[1] == 'search-tags':
613       if len(sys.argv) == 2:
614          #no further search term
615          print("\n".join(Database().get_all_tags()))
616       else:
617          #mangle arguments wrapping terms with spaces in quotes
618          querystr = quote_query_line(sys.argv[2:])
619          db = Database()
620          m  = Query(db,querystr).search_messages()
621          print("\n".join([t for t in m.collect_tags()]))
622    #-------------------------------------
623    elif sys.argv[1] == 'dump':
624       #TODO: implement "dump <filename>"
625       if len(sys.argv) == 2:
626          f = sys.stdout
627       else:
628          f = open(sys.argv[2],"w")
629       db = Database()
630       q = Query(db,'')
631       q.set_sort(Query.SORT.MESSAGE_ID)
632       m = q.search_messages()
633       for msg in m:
634          f.write("%s (%s)\n" % (msg.get_message_id(), msg.get_tags()))
635    #-------------------------------------
636    elif sys.argv[1] == 'restore':
637       import re
638       if len(sys.argv) == 2:
639          print("No filename given. Reading dump from stdin.")
640          f = sys.stdin
641       else:
642          f = open(sys.argv[2],"r")
643       #split the msg id and the tags
644       MSGID_TAGS = re.compile("(\S+)\s\((.*)\)$")
645       db = Database(mode=Database.MODE.READ_WRITE)
646
647       #read each line of the dump file
648       for line in f:
649          m = MSGID_TAGS.match(line)
650          if not m:
651             sys.stderr.write("Warning: Ignoring invalid input line: %s" % 
652                              line)
653             continue
654          # split line in components and fetch message
655          msg_id = m.group(1)
656          new_tags= set(m.group(2).split())
657          msg    = db.find_message(msg_id)
658
659          if msg == None:
660             sys.stderr.write(
661                "Warning: Cannot apply tags to missing message: %s\n" % id)
662             continue
663
664          #do nothing if the old set of tags is the same as the new one
665          old_tags = set(msg.get_tags())
666          if old_tags == new_tags: continue
667
668          #set the new tags
669          msg.freeze()
670          #only remove tags if the new ones are not a superset anyway
671          if not (new_tags > old_tags): msg.remove_all_tags()
672          for tag in new_tags: msg.add_tag(tag)
673          msg.thaw()
674             
675    #-------------------------------------
676    else:
677        # unknown command
678        print "Error: Unknown command '%s' (see \"notmuch help\")" % sys.argv[1]
679
680
681    #TODO: implement
682    """
683 setup
684 new
685 show    <search-terms> [...]
686 reply   [options...] <search-terms> [...]
687 restore <filename>
688    """