2 """This is a notmuch implementation in python.
3 It's goal is to allow running the test suite on the cnotmuch python bindings.
5 This "binary" honors the NOTMUCH_CONFIG environmen variable for reading a user's
6 notmuch configuration (e.g. the database path).
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+.
19 from notmuch import Database, Query, NotmuchError, STATUS
22 from configparser import SafeConfigParser
25 from ConfigParser import SafeConfigParser
26 from cStringIO import StringIO
28 PREFIX = re.compile('(\w+):(.*$)')
30 HELPTEXT = """The notmuch mail system.
31 Usage: notmuch <command> [args...]
33 Where <command> and [args...] are as follows:
34 setup Interactively setup notmuch for first use.
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.
48 Create a plain-text dump of the tags for each message.
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.
54 This message, or more detailed help for the named command.
56 Use "notmuch help <command>" for more details on each command.
57 And "notmuch help search-terms" for the common search-terms syntax.
60 USAGE = """Notmuch is configured and appears to have a database. Excellent!
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"
69 See "notmuch help search" for more details.
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
76 And don't forget to run "notmuch new" whenever new mail arrives.
78 Have fun, and may your inbox never have much mail.
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)
89 argv[num] = '%s:"%s"' %(match.group(1), match.group(2))
91 argv[num] = '"%s"' % item
94 #-------------------------------------------------------------------------
97 class Notmuch(object):
99 def __init__(self, configpath="~/.notmuch-config)"):
101 self._configpath = os.getenv('NOTMUCH_CONFIG',
102 os.path.expanduser(configpath))
105 """Print the usage text and exits"""
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'
113 """Run 'notmuch new'"""
114 #get the database directory
115 db = Database(mode=Database.MODE.READ_WRITE)
117 print self._add_new_files_recursively(path, db)
119 def cmd_help(self, subcmd=None):
120 """Print help text for 'notmuch help'"""
122 print "Help for specific commands not implemented"
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
132 config = SafeConfigParser()
133 config.read(self._configpath)
134 self._config = config
137 def _add_new_files_recursively(self, path, db):
138 """:returns: (added, moved, removed)"""
139 print "Enter add new files with path %s" % path
142 #get the Directory() object for this path
143 db_dir = db.get_directory(path)
144 added = moved = removed = 0
146 # Occurs if we have wrong absolute paths in the db, for example
150 # for folder in subdirs:
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
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():
164 fs_files = set(os.listdir(db_dir.path))
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)
171 if stat.S_ISDIR(statinfo.st_mode):
172 # This is a directory
173 if fs_file in ['.notmuch','tmp','.']:
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)
182 elif stat.S_ISLNK(statinfo.st_mode):
183 print ("%s is a symbolic link (%d). FIXME!!!" %
184 (absfile, statinfo.st_mode))
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
195 if status == STATUS.DUPLICATE_MESSAGE_ID:
196 print "Added msg was in the db"
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))
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)
209 if status == STATUS.SUCCESS:
210 # we just deleted the last reference, so this was a remove
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
218 sys.stderr.write("DUPE %d %s %s.\n" %
219 (status, STATUS.status2str(status), absfile))
221 # This should not occur
222 sys.stderr.write("This should not occur %d %s %s.\n" %
223 (status, STATUS.status2str(status), absfile))
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','.']:
235 new = self._add_new_files_recursively(absfile, db)
240 # we are not interested in anything but directories here
241 #TODO: All remaining elements of db_folders are not in the filesystem
244 return added, moved, removed
245 #Read the mtime of a directory from the filesystem
247 #* Call :meth:`Database.add_message` for all mail files in
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.
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,...)"""
260 #read the config file
261 config = self._get_user_notmuch_config()
263 conf = {'name': '', 'primary_email': ''}
265 if config.has_option('user', entry):
266 conf[entry] = config.get('user', entry)
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]
273 # for being compatible. It would be nicer to return a dict.
274 return conf.keys() + other
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:
280 :param body: a str with a mail body
281 :returns: The new payload of the email.message()
284 # we get handed a string, wrap it in a file-like object
285 oldbody = StringIO(oldbody)
288 newbody.write("On %s, %s wrote:\n" % (date, from_address))
291 newbody.write("> " + line)
293 return newbody.getvalue()
295 def format_reply(self, msgs):
296 """Gets handed Messages() and displays the reply to them
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."""
304 f = open(msg.get_filename(), "r")
305 reply = email.message_from_file(f)
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']))
312 # handle the tricky multipart case
314 """A string describing which nontext attachements
315 that have been deleted"""
317 """A list of payload indices to be deleted"""
318 payloads = reply.get_payload()
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']))
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)):
339 # Back to single- and multipart handling
340 my_addresses = self.get_user_email_addresses()
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
349 addresses = email.utils.getaddresses(reply.get_all(header, []))
351 for (name, mail) in addresses:
352 if mail in my_addresses[1:]:
353 used_address = email.utils.formataddr(
354 (my_addresses[0], mail))
356 purged_addr.append(email.utils.formataddr((name, mail)))
359 reply.replace_header(header, ", ".join(purged_addr))
361 # we deleted all addresses, delete the header
364 # Use our primary email address to the From
365 # (save original from line, we still need it)
366 new_to = reply['From']
368 reply['From'] = used_address
370 email.utils.formataddr((my_addresses[0], my_addresses[1]))
372 reply['Subject'] = 'Re: ' + reply['Subject']
374 # Calculate our new To: field
375 # add all remaining original 'To' addresses
377 new_to += ", " + reply['To']
378 reply.add_header('To', new_to)
380 # Add our primary email address to the BCC
381 new_bcc = my_addresses[1]
383 new_bcc += ', ' + reply['Bcc']
384 reply['Bcc'] = new_bcc
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']
390 #Add original's Message-ID to replies 'References' header.
391 if 'References' in reply:
392 reply['References'] = ' '.join([reply['References'], reply['Message-ID']])
394 reply['References'] = reply['Message-ID']
396 # Delete the original Message-ID.
397 del(reply['Message-ID'])
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'],
404 map(reply.__delitem__, delheaders)
406 # TODO: OUCH, we return after the first msg we have handled rather than
408 # return resulting message without Unixfrom
409 return reply.as_string(False)
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."""
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':
433 #-------------------------------------
434 elif sys.argv[1] == 'search':
436 #-------------------------------------
437 elif sys.argv[1] == 'show':
439 #-------------------------------------
440 elif sys.argv[1] == 'reply':
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
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
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:])
469 # append to remove list without initial -
470 remove.append(sys.argv.pop(2)[1:])
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()
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())
487 # mangle arguments wrapping terms with spaces in quotes
488 querystr = quote_query_line(sys.argv[2:])
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:
497 f = open(sys.argv[2], "w")
499 query = Query(db, '')
500 query.set_sort(Query.SORT.MESSAGE_ID)
501 msgs = query.search_messages()
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.")
510 f = open(sys.argv[2], "r")
512 # split the msg id and the tags
513 MSGID_TAGS = re.compile("(\S+)\s\((.*)\)$")
514 db = Database(mode=Database.MODE.READ_WRITE)
516 #read each line of the dump file
518 msgs = MSGID_TAGS.match(line)
520 sys.stderr.write("Warning: Ignoring invalid input line: %s" %
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)
530 "Warning: Cannot apply tags to missing message: %s\n" % msg_id)
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
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)
543 #-------------------------------------
546 exit("Error: Unknown command '%s' (see \"notmuch help\")" % sys.argv[1])
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]
557 part_num = int(part_num_str)
559 # just emulating behavior
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()]
574 raise Exception("search term did not match precisely one message")
577 print msg.get_part(part_num)
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
593 if first_search_term:
594 # mangle arguments wrapping terms with spaces in quotes
595 querystr = quote_query_line(sys.argv[first_search_term:])
597 qry = Query(db, querystr)
598 if sort_order == "oldest-first":
599 qry.set_sort(Query.SORT.OLDEST_FIRST)
601 qry.set_sort(Query.SORT.NEWEST_FIRST)
602 threads = qry.search_threads()
604 for thread in threads:
608 entire_thread = False
612 first_search_term = None
614 # ugly homegrown option parsing
615 # TODO: use OptionParser
616 for (num, arg) in enumerate(sys.argv[1:]):
617 if arg == '--entire-thread':
619 elif arg.startswith("--format="):
620 out_format = arg.split("=")[1]
621 if out_format == 'json':
622 # for compatibility use --entire-thread for json
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
630 if first_search_term:
631 # mangle arguments wrapping terms with spaces in quotes
632 querystr = quote_query_line(sys.argv[first_search_term:])
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)
646 if out_format == "json":
647 sys.stdout.write("]")
648 sys.stdout.write("\n")
650 if __name__ == '__main__':