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