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