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
20 from ConfigParser import SafeConfigParser
21 from cStringIO import StringIO
23 PREFIX = re.compile('(\w+):(.*$)')
25 HELPTEXT = """The notmuch mail system.
26 Usage: notmuch <command> [args...]
28 Where <command> and [args...] are as follows:
29 setup Interactively setup notmuch for first use.
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.
43 Create a plain-text dump of the tags for each message.
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.
49 This message, or more detailed help for the named command.
51 Use "notmuch help <command>" for more details on each command.
52 And "notmuch help search-terms" for the common search-terms syntax.
55 USAGE = """Notmuch is configured and appears to have a database. Excellent!
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"
64 See "notmuch help search" for more details.
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
71 And don't forget to run "notmuch new" whenever new mail arrives.
73 Have fun, and may your inbox never have much mail.
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)
84 argv[num] = '%s:"%s"' %(match.group(1), match.group(2))
86 argv[num] = '"%s"' % item
89 #-------------------------------------------------------------------------
92 class Notmuch(object):
94 def __init__(self, configpath="~/.notmuch-config)"):
96 self._configpath = os.getenv('NOTMUCH_CONFIG',
97 os.path.expanduser(configpath))
100 """Print the usage text and exits"""
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'
108 """Run 'notmuch new'"""
109 #get the database directory
110 db = Database(mode=Database.MODE.READ_WRITE)
112 print self._add_new_files_recursively(path, db)
114 def cmd_help(self, subcmd=None):
115 """Print help text for 'notmuch help'"""
117 print "Help for specific commands not implemented"
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
127 config = SafeConfigParser()
128 config.read(self._configpath)
129 self._config = config
132 def _add_new_files_recursively(self, path, db):
133 """:returns: (added, moved, removed)"""
134 print "Enter add new files with path %s" % path
137 #get the Directory() object for this path
138 db_dir = db.get_directory(path)
139 added = moved = removed = 0
141 # Occurs if we have wrong absolute paths in the db, for example
145 # for folder in subdirs:
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
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():
159 fs_files = set(os.listdir(db_dir.path))
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)
166 if stat.S_ISDIR(statinfo.st_mode):
167 # This is a directory
168 if fs_file in ['.notmuch','tmp','.']:
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)
177 elif stat.S_ISLNK(statinfo.st_mode):
178 print ("%s is a symbolic link (%d). FIXME!!!" %
179 (absfile, statinfo.st_mode))
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
190 if status == STATUS.DUPLICATE_MESSAGE_ID:
191 print "Added msg was in the db"
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))
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)
204 if status == STATUS.SUCCESS:
205 # we just deleted the last reference, so this was a remove
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
213 sys.stderr.write("DUPE %d %s %s.\n" %
214 (status, STATUS.status2str(status), absfile))
216 # This should not occur
217 sys.stderr.write("This should not occur %d %s %s.\n" %
218 (status, STATUS.status2str(status), absfile))
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','.']:
230 new = self._add_new_files_recursively(absfile, db)
235 # we are not interested in anything but directories here
236 #TODO: All remaining elements of db_folders are not in the filesystem
239 return added, moved, removed
240 #Read the mtime of a directory from the filesystem
242 #* Call :meth:`Database.add_message` for all mail files in
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.
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,...)"""
255 #read the config file
256 config = self._get_user_notmuch_config()
258 conf = {'name': '', 'primary_email': ''}
260 if config.has_option('user', entry):
261 conf[entry] = config.get('user', entry)
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]
268 # for being compatible. It would be nicer to return a dict.
269 return conf.keys() + other
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:
275 :param body: a str with a mail body
276 :returns: The new payload of the email.message()
279 # we get handed a string, wrap it in a file-like object
280 oldbody = StringIO(oldbody)
283 newbody.write("On %s, %s wrote:\n" % (date, from_address))
286 newbody.write("> " + line)
288 return newbody.getvalue()
290 def format_reply(self, msgs):
291 """Gets handed Messages() and displays the reply to them
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."""
299 f = open(msg.get_filename(), "r")
300 reply = email.message_from_file(f)
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']))
307 # handle the tricky multipart case
309 """A string describing which nontext attachements
310 that have been deleted"""
312 """A list of payload indices to be deleted"""
313 payloads = reply.get_payload()
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']))
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)):
334 # Back to single- and multipart handling
335 my_addresses = self.get_user_email_addresses()
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
344 addresses = email.utils.getaddresses(reply.get_all(header, []))
346 for (name, mail) in addresses:
347 if mail in my_addresses[1:]:
348 used_address = email.utils.formataddr(
349 (my_addresses[0], mail))
351 purged_addr.append(email.utils.formataddr((name, mail)))
354 reply.replace_header(header, ", ".join(purged_addr))
356 # we deleted all addresses, delete the header
359 # Use our primary email address to the From
360 # (save original from line, we still need it)
361 new_to = reply['From']
363 reply['From'] = used_address
365 email.utils.formataddr((my_addresses[0], my_addresses[1]))
367 reply['Subject'] = 'Re: ' + reply['Subject']
369 # Calculate our new To: field
370 # add all remaining original 'To' addresses
372 new_to += ", " + reply['To']
373 reply.add_header('To', new_to)
375 # Add our primary email address to the BCC
376 new_bcc = my_addresses[1]
378 new_bcc += ', ' + reply['Bcc']
379 reply['Bcc'] = new_bcc
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']
385 #Add original's Message-ID to replies 'References' header.
386 if 'References' in reply:
387 reply['References'] = ' '.join([reply['References'], reply['Message-ID']])
389 reply['References'] = reply['Message-ID']
391 # Delete the original Message-ID.
392 del(reply['Message-ID'])
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'],
399 map(reply.__delitem__, delheaders)
401 # TODO: OUCH, we return after the first msg we have handled rather than
403 # return resulting message without Unixfrom
404 return reply.as_string(False)
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."""
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':
428 #-------------------------------------
429 elif sys.argv[1] == 'search':
431 #-------------------------------------
432 elif sys.argv[1] == 'show':
434 #-------------------------------------
435 elif sys.argv[1] == 'reply':
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
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
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:])
464 # append to remove list without initial -
465 remove.append(sys.argv.pop(2)[1:])
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()
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())
482 # mangle arguments wrapping terms with spaces in quotes
483 querystr = quote_query_line(sys.argv[2:])
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:
492 f = open(sys.argv[2], "w")
494 query = Query(db, '')
495 query.set_sort(Query.SORT.MESSAGE_ID)
496 msgs = query.search_messages()
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.")
505 f = open(sys.argv[2], "r")
507 # split the msg id and the tags
508 MSGID_TAGS = re.compile("(\S+)\s\((.*)\)$")
509 db = Database(mode=Database.MODE.READ_WRITE)
511 #read each line of the dump file
513 msgs = MSGID_TAGS.match(line)
515 sys.stderr.write("Warning: Ignoring invalid input line: %s" %
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)
525 "Warning: Cannot apply tags to missing message: %s\n" % msg_id)
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
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)
538 #-------------------------------------
541 exit("Error: Unknown command '%s' (see \"notmuch help\")" % sys.argv[1])
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]
552 part_num = int(part_num_str)
554 # just emulating behavior
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()]
569 raise Exception("search term did not match precisely one message")
572 print msg.get_part(part_num)
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
588 if first_search_term:
589 # mangle arguments wrapping terms with spaces in quotes
590 querystr = quote_query_line(sys.argv[first_search_term:])
592 qry = Query(db, querystr)
593 if sort_order == "oldest-first":
594 qry.set_sort(Query.SORT.OLDEST_FIRST)
596 qry.set_sort(Query.SORT.NEWEST_FIRST)
597 threads = qry.search_threads()
599 for thread in threads:
603 entire_thread = False
607 first_search_term = None
609 # ugly homegrown option parsing
610 # TODO: use OptionParser
611 for (num, arg) in enumerate(sys.argv[1:]):
612 if arg == '--entire-thread':
614 elif arg.startswith("--format="):
615 out_format = arg.split("=")[1]
616 if out_format == 'json':
617 # for compatibility use --entire-thread for json
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
625 if first_search_term:
626 # mangle arguments wrapping terms with spaces in quotes
627 querystr = quote_query_line(sys.argv[first_search_term:])
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)
641 if out_format == "json":
642 sys.stdout.write("]")
643 sys.stdout.write("\n")
645 if __name__ == '__main__':