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+.
12 from __future__ import with_statement # This isn't required in Python 2.6
13 import sys, os, re, stat
14 from cnotmuch.notmuch import Database, Query, NotmuchError, STATUS
15 PREFIX=re.compile('(\w+):(.*$)')
16 #-------------------------------------------------------------------------
17 def quote_query_line(argv):
18 #mangle arguments wrapping terms with spaces in quotes
19 for i in xrange(0,len(argv)):
20 if argv[i].find(' ') >= 0:
21 #if we use prefix:termWithSpaces, put quotes around term
22 m = PREFIX.match(argv[i])
24 argv[i] = '%s:"%s"' % (m.group(1), m.group(2))
26 argv[i] = '"'+argv[i]+'"'
29 #-------------------------------------------------------------------------
36 """Print the usage text and exits"""
38 names = self.get_user_email_addresses()
39 data['fullname'] =names[0] if names[0] else 'My Name'
40 data['mailaddress']=names[1] if names[1] else 'My@email.address'
41 print (Notmuch.USAGE % data)
44 """Run 'notmuch new'"""
45 #get the database directory
46 db = Database(mode=Database.MODE.READ_WRITE)
49 (added, moved, removed) = self._add_new_files_recursively(path, db)
50 print (added, moved, removed)
52 def cmd_help(self, subcmd=None):
53 """Print help text for 'notmuch help'"""
55 print "Help for specific commands not implemented"
58 print (Notmuch.HELPTEXT)
60 def _get_user_notmuch_config(self):
61 """Returns the ConfigParser of the user's notmuch-config"""
62 # return the cached config parser if we read it already
63 if self._config is not None:
66 from ConfigParser import SafeConfigParser
67 config = SafeConfigParser()
68 conf_f = os.getenv('NOTMUCH_CONFIG',
69 os.path.expanduser('~/.notmuch-config'))
74 def _add_new_files_recursively(self, path, db):
75 """:returns: (added, moved, removed)"""
76 print "Enter add new files with path %s" % path
77 (added, moved, removed) = (0,)*3
80 #get the Directory() object for this path
81 db_dir = db.get_directory(path)
83 #Occurs if we have wrong absolute paths in the db, for example
87 #for folder in subdirs:
88 # (new_added, new_moved, new_removed) = \
89 # self._add_new_files_recursively(
90 # os.path.join(db_dir.path, folder), db)
93 # removed += new_removed
95 #TODO, retrieve dir mtime here and store it later
96 #as long as Filenames() does not allow multiple iteration, we need to
97 #use this kludgy way to get a sorted list of filenames
98 #db_files is a list of subdirectories and filenames in this folder
101 for subdir in db_dir.get_child_directories():
102 db_folders.add(os.path.normpath(subdir))
103 for file in db_dir.get_child_files():
106 fs_files = set(os.listdir(db_dir.path))
108 #list of folders in both db and fs. Just descend into dirs
109 for fs_file in (fs_files | db_folders):
110 absfile = os.path.normpath(os.path.join(db_dir.path, fs_file))
111 if os.path.isdir(absfile):
113 if fs_file in ['.notmuch','tmp','.']:
115 self._add_new_files_recursively(absfile, db)
116 # we are not interested in anything but directories here
118 #list of files and folders in the fs, but not the db
119 for fs_file in (fs_files - db_files):
120 absfile = os.path.normpath(os.path.join(db_dir.path, fs_file))
121 statinfo = os.stat(absfile)
123 if stat.S_ISDIR(statinfo.st_mode):
125 if fs_file in ['.notmuch','.']:
127 print "descending into %s" % absfile
128 #self._add_new_files_recursively(absfile, db)
129 elif stat.S_ISLNK(statinfo.st_mode):
130 print ("%s is a symbolic link (%d)" % (absfile, statinfo.st_mode))
132 print "This file needs to be added %s" % (absfile)
134 #(msg, status) = db.add_message(os.path.join(db_dir.path, db_file))
135 #if status == STATUS.DUPLICATE_MESSAGE_ID:
136 # #This message was already in the database, continue with next one
139 #list of files and folders in the database, but not the filesystem
140 for db_file in (db_files - fs_files):
141 absfile = os.path.normpath(os.path.join(db_dir.path, db_file))
142 statinfo = os.stat(absfile)
144 if stat.S_ISDIR(statinfo.st_mode):
146 if db_file in ['.notmuch', '.']:
148 print "descending into %s" % absfile
149 self._add_new_files_recursively(absfile, db)
150 #TODO, is there no way to REMOVE a directory entry from the db?
152 #remove a mail message from the db
153 print ("%s is not on the fs anymore. Delete" % absfile)
154 status = db.remove_message(absfile)
155 if status == STATUS.SUCCESS:
156 # we just deleted the last reference, so this was a remove
158 elif status == STATUS.DUPLICATE_MESSAGE_ID:
159 # The filename exists already somewhere else, so this is a move
162 print "This must not happen. %s " % (absfile)
165 return (added, moved, removed)
166 #Read the mtime of a directory from the filesystem
168 #* Call :meth:`Database.add_message` for all mail files in
171 #* Call notmuch_directory_set_mtime with the mtime read from the
172 # filesystem. Then, when wanting to check for updates to the
173 # directory in the future, the client can call :meth:`get_mtime`
174 # and know that it only needs to add files if the mtime of the
175 # directory and files are newer than the stored timestamp.
177 def get_user_email_addresses(self):
178 """ Reads a user's notmuch config and returns his email addresses as
179 list (name, primary_address, other_address1,...)"""
182 #read the config file
183 config = self._get_user_notmuch_config()
185 if not config.has_option('user','name'): name = ""
186 else:name = config.get('user','name')
188 if not config.has_option('user','primary_email'): mail = ""
189 else:mail = config.get('user','primary_email')
191 if not config.has_option('user','other_email'): other = []
192 else:other = config.get('user','other_email').rstrip(';').split(';')
194 other.insert(0, mail)
195 other.insert(0, name)
198 def quote_msg_body(self, oldbody ,date, from_address):
199 """Transform a mail body into a quoted text,
200 starting with On blah, x wrote:
202 :param body: a str with a mail body
203 :returns: The new payload of the email.message()
205 from cStringIO import StringIO
207 #we get handed a string, wrap it in a file-like object
208 oldbody = StringIO(oldbody)
211 newbody.write("On %s, %s wrote:\n" % (date, from_address))
214 newbody.write("> " + line)
216 return newbody.getvalue()
218 def format_reply(self, msgs):
219 """Gets handed Messages() and displays the reply to them
221 This is pretty ugly and hacky. It tries to mimic the "real"
222 notmuch output as much as it can to pass the test suite. It
223 could deserve a healthy bit of love. It is also buggy because
224 it returns after the first message it has handled."""
228 f = open(msg.get_filename(),"r")
229 reply = email.message_from_file(f)
231 #handle the easy non-multipart case:
232 if not reply.is_multipart():
233 reply.set_payload(self.quote_msg_body(reply.get_payload(),
234 reply['date'],reply['from']))
236 #handle the tricky multipart case
238 """A string describing which nontext attachements that
241 """A list of payload indices to be deleted"""
243 payloads = reply.get_payload()
245 for i, part in enumerate(payloads):
247 mime_main = part.get_content_maintype()
248 if mime_main not in ['multipart', 'message', 'text']:
249 deleted += "Non-text part: %s\n" % (part.get_content_type())
250 payloads[i].set_payload("Non-text part: %s" % (part.get_content_type()))
251 payloads[i].set_type('text/plain')
252 delpayloads.append(i)
253 elif mime_main == 'text':
254 payloads[i].set_payload(self.quote_msg_body(payloads[i].get_payload(),reply['date'],reply['from']))
256 #TODO handle deeply nested multipart messages
257 sys.stderr.write ("FIXME: Ignoring multipart part. Handle me\n")
259 # Delete those payloads that we don't need anymore
260 for i in reversed(sorted(delpayloads)):
263 #Back to single- and multipart handling
264 my_addresses = self.get_user_email_addresses()
266 # filter our email addresses from all to: cc: and bcc: fields
267 # if we find one of "my" addresses being used,
268 # it is stored in used_address
269 for header in ['To', 'CC', 'Bcc']:
270 if not header in reply:
271 #only handle fields that exist
273 addresses = email.utils.getaddresses(reply.get_all(header,[]))
275 for name, mail in addresses:
276 if mail in my_addresses[1:]:
277 used_address = email.utils.formataddr((my_addresses[0],mail))
279 purged_addr.append(email.utils.formataddr((name,mail)))
282 reply.replace_header(header, ", ".join(purged_addr))
284 #we deleted all addresses, delete the header
287 # Use our primary email address to the From
288 # (save original from line, we still need it)
289 orig_from = reply['From']
291 reply['From'] = used_address if used_address \
292 else email.utils.formataddr((my_addresses[0],my_addresses[1]))
294 #reinsert the Subject after the From
295 orig_subject = reply['Subject']
297 reply['Subject'] = 'Re: ' + orig_subject
299 # Calculate our new To: field
301 # add all remaining original 'To' addresses
303 new_to += ", " + reply['To']
305 reply.add_header('To', new_to)
307 # Add our primary email address to the BCC
308 new_bcc = my_addresses[1]
309 if reply.has_key('Bcc'):
310 new_bcc += ', ' + reply['Bcc']
312 reply['Bcc'] = new_bcc
314 # Set replies 'In-Reply-To' header to original's Message-ID
315 if reply.has_key('Message-ID') :
316 del reply['In-Reply-To']
317 reply['In-Reply-To'] = reply['Message-ID']
319 #Add original's Message-ID to replies 'References' header.
320 if reply.has_key('References'):
321 ref = reply['References'] + ' ' +reply['Message-ID']
323 ref = reply['Message-ID']
324 del reply['References']
325 reply['References'] = ref
327 # Delete the original Message-ID.
328 del(reply['Message-ID'])
330 # filter all existing headers but a few and delete them from 'reply'
331 delheaders = filter(lambda x: x not in ['From','To','Subject','CC',
333 'References','Content-Type'],
335 map(reply.__delitem__, delheaders)
337 # TODO: OUCH, we return after the first msg we have handled rather than
339 #return resulting message without Unixfrom
340 return reply.as_string(False)
343 HELPTEXT="""The notmuch mail system.
345 Usage: notmuch <command> [args...]
347 Where <command> and [args...] are as follows:
349 setup Interactively setup notmuch for first use.
353 Find and import new messages to the notmuch database.
355 search [options...] <search-terms> [...]
357 Search for messages matching the given search terms.
359 show <search-terms> [...]
361 Show all messages matching the search terms.
363 count <search-terms> [...]
365 Count messages matching the search terms.
367 reply [options...] <search-terms> [...]
369 Construct a reply template for a set of messages.
371 tag +<tag>|-<tag> [...] [--] <search-terms> [...]
373 Add/remove tags for all messages matching the search terms.
377 Create a plain-text dump of the tags for each message.
381 Restore the tags from the given dump file (see 'dump').
383 search-tags [<search-terms> [...] ]
385 List all tags found in the database or matching messages.
389 This message, or more detailed help for the named command.
391 Use "notmuch help <command>" for more details on each command.
392 And "notmuch help search-terms" for the common search-terms syntax.
395 USAGE="""Notmuch is configured and appears to have a database. Excellent!
397 At this point you can start exploring the functionality of notmuch by
398 using commands such as:
400 notmuch search tag:inbox
402 notmuch search to:"%(fullname)s"
404 notmuch search from:"%(mailaddress)s"
406 notmuch search subject:"my favorite things"
408 See "notmuch help search" for more details.
410 You can also use "notmuch show" with any of the thread IDs resulting
411 from a search. Finally, you may want to explore using a more sophisticated
412 interface to notmuch such as the emacs interface implemented in notmuch.el
413 or any other interface described at http://notmuchmail.org
415 And don't forget to run "notmuch new" whenever new mail arrives.
417 Have fun, and may your inbox never have much mail.
421 #-------------------------------------------------------------------------
422 if __name__ == '__main__':
424 # Handle command line options
425 #-------------------------------------
426 # No option given, print USAGE and exit
427 if len(sys.argv) == 1:
428 Notmuch().cmd_usage()
429 #-------------------------------------
430 elif sys.argv[1] == 'setup':
431 """Interactively setup notmuch for first use."""
432 print "Not implemented."
433 #-------------------------------------
434 elif sys.argv[1] == 'new':
435 """Check for new and removed messages."""
437 #-------------------------------------
438 elif sys.argv[1] == 'help':
439 """Print the help text"""
440 Notmuch().cmd_help(sys.argv[1:])
441 #-------------------------------------
442 elif sys.argv[1] == 'part':
446 first_search_term = None
447 for (i, arg) in enumerate(sys.argv[1:]):
448 if arg.startswith('--part='):
449 part_num_str=arg.split("=")[1]
451 part_num = int(part_num_str)
453 # just emulating behavior
455 elif not arg.startswith('--'):
456 #save the position of the first sys.argv that is a search term
457 first_search_term = i+1
459 if first_search_term:
460 #mangle arguments wrapping terms with spaces in quotes
461 querystr = quote_query_line(sys.argv[first_search_term:])
463 qry = Query(db,querystr)
464 msgs = qry.search_messages()
469 if len(msg_list) == 0:
471 elif len(msg_list) > 1:
472 raise Exception("search term did not match precisely one message")
475 print(msg.get_part(part_num))
476 #-------------------------------------
477 elif sys.argv[1] == 'search':
480 sort_order="newest-first"
481 first_search_term = None
482 for (i, arg) in enumerate(sys.argv[1:]):
483 if arg.startswith('--sort='):
484 sort_order=arg.split("=")[1]
485 if not sort_order in ("oldest-first", "newest-first"):
486 raise Exception("unknown sort order")
487 elif not arg.startswith('--'):
488 #save the position of the first sys.argv that is a search term
489 first_search_term = i+1
491 if first_search_term:
492 #mangle arguments wrapping terms with spaces in quotes
493 querystr = quote_query_line(sys.argv[first_search_term:])
495 qry = Query(db,querystr)
496 if sort_order == "oldest-first":
497 qry.set_sort(Query.SORT.OLDEST_FIRST)
499 qry.set_sort(Query.SORT.NEWEST_FIRST)
500 t = qry.search_threads()
505 #-------------------------------------
506 elif sys.argv[1] == 'show':
507 entire_thread = False
511 first_search_term = None
513 #ugly homegrown option parsing
514 #TODO: use OptionParser
515 for (i, arg) in enumerate(sys.argv[1:]):
516 if arg == '--entire-thread':
518 elif arg.startswith("--format="):
519 out_format = arg.split("=")[1]
520 if out_format == 'json':
521 #for compatibility use --entire-thread for json
523 if not out_format in ("json", "text"):
524 raise Exception("unknown format")
525 elif not arg.startswith('--'):
526 #save the position of the first sys.argv that is a search term
527 first_search_term = i+1
529 if first_search_term:
530 #mangle arguments wrapping terms with spaces in quotes
531 querystr = quote_query_line(sys.argv[first_search_term:])
533 t = Query(db,querystr).search_threads()
536 if out_format.lower()=="json":
537 sys.stdout.write("[")
540 msgs = thrd.get_toplevel_messages()
542 if not first_toplevel:
543 if out_format.lower()=="json":
544 sys.stdout.write(", ")
546 first_toplevel = False
548 msgs.print_messages(out_format, 0, entire_thread)
550 if out_format.lower() == "json":
551 sys.stdout.write("]")
552 sys.stdout.write("\n")
554 #-------------------------------------
555 elif sys.argv[1] == 'reply':
557 if len(sys.argv) == 2:
558 #no search term. abort
559 print("Error: notmuch reply requires at least one search term.")
562 #mangle arguments wrapping terms with spaces in quotes
563 querystr = quote_query_line(sys.argv[2:])
564 msgs = Query(db,querystr).search_messages()
565 print (Notmuch().format_reply(msgs))
567 #-------------------------------------
568 elif sys.argv[1] == 'count':
569 if len(sys.argv) == 2:
570 #no further search term, count all
573 #mangle arguments wrapping terms with spaces in quotes
574 querystr = quote_query_line(sys.argv[2:])
575 print(Database().create_query(querystr).count_messages())
577 #-------------------------------------
578 elif sys.argv[1] == 'tag':
579 #build lists of tags to be added and removed
581 while not sys.argv[2]=='--' and \
582 (sys.argv[2].startswith('+') or sys.argv[2].startswith('-')):
583 if sys.argv[2].startswith('+'):
584 #append to add list without initial +
585 add.append(sys.argv.pop(2)[1:])
587 #append to remove list without initial -
588 remove.append(sys.argv.pop(2)[1:])
590 if sys.argv[2]=='--': sys.argv.pop(2)
591 #the rest is search terms
592 querystr = quote_query_line(sys.argv[2:])
593 db = Database(mode=Database.MODE.READ_WRITE)
594 m = Query(db,querystr).search_messages()
596 #actually add and remove all tags
597 map(msg.add_tag, add)
598 map(msg.remove_tag, remove)
599 #-------------------------------------
600 elif sys.argv[1] == 'search-tags':
601 if len(sys.argv) == 2:
602 #no further search term
603 print("\n".join(Database().get_all_tags()))
605 #mangle arguments wrapping terms with spaces in quotes
606 querystr = quote_query_line(sys.argv[2:])
608 m = Query(db,querystr).search_messages()
609 print("\n".join([t for t in m.collect_tags()]))
610 #-------------------------------------
611 elif sys.argv[1] == 'dump':
612 #TODO: implement "dump <filename>"
613 if len(sys.argv) == 2:
616 f = open(sys.argv[2],"w")
619 q.set_sort(Query.SORT.MESSAGE_ID)
620 m = q.search_messages()
622 f.write("%s (%s)\n" % (msg.get_message_id(), msg.get_tags()))
623 #-------------------------------------
624 elif sys.argv[1] == 'restore':
626 if len(sys.argv) == 2:
627 print("No filename given. Reading dump from stdin.")
630 f = open(sys.argv[2],"r")
631 #split the msg id and the tags
632 MSGID_TAGS = re.compile("(\S+)\s\((.*)\)$")
633 db = Database(mode=Database.MODE.READ_WRITE)
635 #read each line of the dump file
637 m = MSGID_TAGS.match(line)
639 sys.stderr.write("Warning: Ignoring invalid input line: %s" %
642 # split line in components and fetch message
644 new_tags= set(m.group(2).split())
645 msg = db.find_message(msg_id)
649 "Warning: Cannot apply tags to missing message: %s\n" % id)
652 #do nothing if the old set of tags is the same as the new one
653 old_tags = set(msg.get_tags())
654 if old_tags == new_tags: continue
658 #only remove tags if the new ones are not a superset anyway
659 if not (new_tags > old_tags): msg.remove_all_tags()
660 for tag in new_tags: msg.add_tag(tag)
663 #-------------------------------------
666 print "Error: Unknown command '%s' (see \"notmuch help\")" % sys.argv[1]
673 show <search-terms> [...]
674 reply [options...] <search-terms> [...]