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