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:
89 #TODO, retrieve dir mtime here and store it later
90 #as long as Filenames() does not allow multiple iteration, we need to
91 #use this kludgy way to get a sorted list of filenames
92 #db_files is a list of subdirectories and filenames in this folder
95 for subdir in db_dir.get_child_directories():
96 db_folders.add(subdir)
97 for file in db_dir.get_child_files():
100 fs_files = set(os.listdir(db_dir.path))
102 #list of files (and folders) on the fs, but not the db
103 for fs_file in ((fs_files - db_files) - db_folders):
104 absfile = os.path.normpath(os.path.join(db_dir.path, fs_file))
105 statinfo = os.stat(absfile)
107 if stat.S_ISDIR(statinfo.st_mode):
109 if fs_file in ['.notmuch','tmp','.']:
111 print "%s %s" % (fs_file, db_folders)
112 print "Directory not in db yet. Descending into %s" % absfile
113 (new_added, new_moved, new_removed) = \
114 self._add_new_files_recursively(absfile, db)
117 removed += new_removed
119 elif stat.S_ISLNK(statinfo.st_mode):
120 print ("%s is a symbolic link (%d). FIXME!!!" % (absfile, statinfo.st_mode))
123 #This is a regular file, not in the db yet. Add it.
124 print "This file needs to be added %s" % (absfile)
125 (msg, status) = db.add_message(absfile)
126 # We increases 'added', even on dupe messages. If it is a moved
127 # message, we will deduct it later and increase 'moved' instead
130 if status == STATUS.DUPLICATE_MESSAGE_ID:
131 #This message was already in the database
132 print "Added msg was in the db"
136 # Finally a list of files (not folders) in the database,
137 # but not the filesystem
138 for db_file in (db_files - fs_files):
139 absfile = os.path.normpath(os.path.join(db_dir.path, db_file))
141 #remove a mail message from the db
142 print ("%s is not on the fs anymore. Delete" % absfile)
143 status = db.remove_message(absfile)
144 if status == STATUS.SUCCESS:
145 # we just deleted the last reference, so this was a remove
147 sys.stderr.write("SUCCESS %d %s %s.\n" % (status, STATUS.status2str(status), absfile))
148 elif status == STATUS.DUPLICATE_MESSAGE_ID:
149 # The filename exists already somewhere else, so this is a move
152 sys.stderr.write("DUPE %d %s %s.\n" % (status, STATUS.status2str(status), absfile))
154 #This should not occur
155 sys.stderr.write("This should not occur %d %s %s.\n" % (status, STATUS.status2str(status), absfile))
157 #list of folders in the filesystem. Just descend into dirs
158 for fs_file in fs_files:
159 absfile = os.path.normpath(os.path.join(db_dir.path, fs_file))
160 if os.path.isdir(absfile):
161 #This is a directory.
162 # Remove it from the db_folder list. All remaining db_folders
163 # at the end will be not present on the file system.
164 db_folders.remove(fs_file)
165 if fs_file in ['.notmuch','tmp','.']:
167 (new_added, new_moved, new_removed) = \
168 self._add_new_files_recursively(absfile, db)
171 removed += new_removed
173 # we are not interested in anything but directories here
174 #TODO: All remaining elements of db_folders are not in the filesystem
177 return (added, moved, removed)
178 #Read the mtime of a directory from the filesystem
180 #* Call :meth:`Database.add_message` for all mail files in
183 #* Call notmuch_directory_set_mtime with the mtime read from the
184 # filesystem. Then, when wanting to check for updates to the
185 # directory in the future, the client can call :meth:`get_mtime`
186 # and know that it only needs to add files if the mtime of the
187 # directory and files are newer than the stored timestamp.
189 def get_user_email_addresses(self):
190 """ Reads a user's notmuch config and returns his email addresses as
191 list (name, primary_address, other_address1,...)"""
194 #read the config file
195 config = self._get_user_notmuch_config()
197 if not config.has_option('user','name'): name = ""
198 else:name = config.get('user','name')
200 if not config.has_option('user','primary_email'): mail = ""
201 else:mail = config.get('user','primary_email')
203 if not config.has_option('user','other_email'): other = []
204 else:other = config.get('user','other_email').rstrip(';').split(';')
206 other.insert(0, mail)
207 other.insert(0, name)
210 def quote_msg_body(self, oldbody ,date, from_address):
211 """Transform a mail body into a quoted text,
212 starting with On blah, x wrote:
214 :param body: a str with a mail body
215 :returns: The new payload of the email.message()
217 from cStringIO import StringIO
219 #we get handed a string, wrap it in a file-like object
220 oldbody = StringIO(oldbody)
223 newbody.write("On %s, %s wrote:\n" % (date, from_address))
226 newbody.write("> " + line)
228 return newbody.getvalue()
230 def format_reply(self, msgs):
231 """Gets handed Messages() and displays the reply to them
233 This is pretty ugly and hacky. It tries to mimic the "real"
234 notmuch output as much as it can to pass the test suite. It
235 could deserve a healthy bit of love. It is also buggy because
236 it returns after the first message it has handled."""
240 f = open(msg.get_filename(),"r")
241 reply = email.message_from_file(f)
243 #handle the easy non-multipart case:
244 if not reply.is_multipart():
245 reply.set_payload(self.quote_msg_body(reply.get_payload(),
246 reply['date'],reply['from']))
248 #handle the tricky multipart case
250 """A string describing which nontext attachements that
253 """A list of payload indices to be deleted"""
255 payloads = reply.get_payload()
257 for i, part in enumerate(payloads):
259 mime_main = part.get_content_maintype()
260 if mime_main not in ['multipart', 'message', 'text']:
261 deleted += "Non-text part: %s\n" % (part.get_content_type())
262 payloads[i].set_payload("Non-text part: %s" % (part.get_content_type()))
263 payloads[i].set_type('text/plain')
264 delpayloads.append(i)
265 elif mime_main == 'text':
266 payloads[i].set_payload(self.quote_msg_body(payloads[i].get_payload(),reply['date'],reply['from']))
268 #TODO handle deeply nested multipart messages
269 sys.stderr.write ("FIXME: Ignoring multipart part. Handle me\n")
271 # Delete those payloads that we don't need anymore
272 for i in reversed(sorted(delpayloads)):
275 #Back to single- and multipart handling
276 my_addresses = self.get_user_email_addresses()
278 # filter our email addresses from all to: cc: and bcc: fields
279 # if we find one of "my" addresses being used,
280 # it is stored in used_address
281 for header in ['To', 'CC', 'Bcc']:
282 if not header in reply:
283 #only handle fields that exist
285 addresses = email.utils.getaddresses(reply.get_all(header,[]))
287 for name, mail in addresses:
288 if mail in my_addresses[1:]:
289 used_address = email.utils.formataddr((my_addresses[0],mail))
291 purged_addr.append(email.utils.formataddr((name,mail)))
294 reply.replace_header(header, ", ".join(purged_addr))
296 #we deleted all addresses, delete the header
299 # Use our primary email address to the From
300 # (save original from line, we still need it)
301 orig_from = reply['From']
303 reply['From'] = used_address if used_address \
304 else email.utils.formataddr((my_addresses[0],my_addresses[1]))
306 #reinsert the Subject after the From
307 orig_subject = reply['Subject']
309 reply['Subject'] = 'Re: ' + orig_subject
311 # Calculate our new To: field
313 # add all remaining original 'To' addresses
315 new_to += ", " + reply['To']
317 reply.add_header('To', new_to)
319 # Add our primary email address to the BCC
320 new_bcc = my_addresses[1]
321 if reply.has_key('Bcc'):
322 new_bcc += ', ' + reply['Bcc']
324 reply['Bcc'] = new_bcc
326 # Set replies 'In-Reply-To' header to original's Message-ID
327 if reply.has_key('Message-ID') :
328 del reply['In-Reply-To']
329 reply['In-Reply-To'] = reply['Message-ID']
331 #Add original's Message-ID to replies 'References' header.
332 if reply.has_key('References'):
333 ref = reply['References'] + ' ' +reply['Message-ID']
335 ref = reply['Message-ID']
336 del reply['References']
337 reply['References'] = ref
339 # Delete the original Message-ID.
340 del(reply['Message-ID'])
342 # filter all existing headers but a few and delete them from 'reply'
343 delheaders = filter(lambda x: x not in ['From','To','Subject','CC',
345 'References','Content-Type'],
347 map(reply.__delitem__, delheaders)
349 # TODO: OUCH, we return after the first msg we have handled rather than
351 #return resulting message without Unixfrom
352 return reply.as_string(False)
355 HELPTEXT="""The notmuch mail system.
357 Usage: notmuch <command> [args...]
359 Where <command> and [args...] are as follows:
361 setup Interactively setup notmuch for first use.
365 Find and import new messages to the notmuch database.
367 search [options...] <search-terms> [...]
369 Search for messages matching the given search terms.
371 show <search-terms> [...]
373 Show all messages matching the search terms.
375 count <search-terms> [...]
377 Count messages matching the search terms.
379 reply [options...] <search-terms> [...]
381 Construct a reply template for a set of messages.
383 tag +<tag>|-<tag> [...] [--] <search-terms> [...]
385 Add/remove tags for all messages matching the search terms.
389 Create a plain-text dump of the tags for each message.
393 Restore the tags from the given dump file (see 'dump').
395 search-tags [<search-terms> [...] ]
397 List all tags found in the database or matching messages.
401 This message, or more detailed help for the named command.
403 Use "notmuch help <command>" for more details on each command.
404 And "notmuch help search-terms" for the common search-terms syntax.
407 USAGE="""Notmuch is configured and appears to have a database. Excellent!
409 At this point you can start exploring the functionality of notmuch by
410 using commands such as:
412 notmuch search tag:inbox
414 notmuch search to:"%(fullname)s"
416 notmuch search from:"%(mailaddress)s"
418 notmuch search subject:"my favorite things"
420 See "notmuch help search" for more details.
422 You can also use "notmuch show" with any of the thread IDs resulting
423 from a search. Finally, you may want to explore using a more sophisticated
424 interface to notmuch such as the emacs interface implemented in notmuch.el
425 or any other interface described at http://notmuchmail.org
427 And don't forget to run "notmuch new" whenever new mail arrives.
429 Have fun, and may your inbox never have much mail.
433 #-------------------------------------------------------------------------
434 if __name__ == '__main__':
436 # Handle command line options
437 #-------------------------------------
438 # No option given, print USAGE and exit
439 if len(sys.argv) == 1:
440 Notmuch().cmd_usage()
441 #-------------------------------------
442 elif sys.argv[1] == 'setup':
443 """Interactively setup notmuch for first use."""
444 print "Not implemented."
445 #-------------------------------------
446 elif sys.argv[1] == 'new':
447 """Check for new and removed messages."""
449 #-------------------------------------
450 elif sys.argv[1] == 'help':
451 """Print the help text"""
452 Notmuch().cmd_help(sys.argv[1:])
453 #-------------------------------------
454 elif sys.argv[1] == 'part':
458 first_search_term = None
459 for (i, arg) in enumerate(sys.argv[1:]):
460 if arg.startswith('--part='):
461 part_num_str=arg.split("=")[1]
463 part_num = int(part_num_str)
465 # just emulating behavior
467 elif not arg.startswith('--'):
468 #save the position of the first sys.argv that is a search term
469 first_search_term = i+1
471 if first_search_term:
472 #mangle arguments wrapping terms with spaces in quotes
473 querystr = quote_query_line(sys.argv[first_search_term:])
475 qry = Query(db,querystr)
476 msgs = qry.search_messages()
481 if len(msg_list) == 0:
483 elif len(msg_list) > 1:
484 raise Exception("search term did not match precisely one message")
487 print(msg.get_part(part_num))
488 #-------------------------------------
489 elif sys.argv[1] == 'search':
492 sort_order="newest-first"
493 first_search_term = None
494 for (i, arg) in enumerate(sys.argv[1:]):
495 if arg.startswith('--sort='):
496 sort_order=arg.split("=")[1]
497 if not sort_order in ("oldest-first", "newest-first"):
498 raise Exception("unknown sort order")
499 elif not arg.startswith('--'):
500 #save the position of the first sys.argv that is a search term
501 first_search_term = i+1
503 if first_search_term:
504 #mangle arguments wrapping terms with spaces in quotes
505 querystr = quote_query_line(sys.argv[first_search_term:])
507 qry = Query(db,querystr)
508 if sort_order == "oldest-first":
509 qry.set_sort(Query.SORT.OLDEST_FIRST)
511 qry.set_sort(Query.SORT.NEWEST_FIRST)
512 t = qry.search_threads()
517 #-------------------------------------
518 elif sys.argv[1] == 'show':
519 entire_thread = False
523 first_search_term = None
525 #ugly homegrown option parsing
526 #TODO: use OptionParser
527 for (i, arg) in enumerate(sys.argv[1:]):
528 if arg == '--entire-thread':
530 elif arg.startswith("--format="):
531 out_format = arg.split("=")[1]
532 if out_format == 'json':
533 #for compatibility use --entire-thread for json
535 if not out_format in ("json", "text"):
536 raise Exception("unknown format")
537 elif not arg.startswith('--'):
538 #save the position of the first sys.argv that is a search term
539 first_search_term = i+1
541 if first_search_term:
542 #mangle arguments wrapping terms with spaces in quotes
543 querystr = quote_query_line(sys.argv[first_search_term:])
545 t = Query(db,querystr).search_threads()
548 if out_format.lower()=="json":
549 sys.stdout.write("[")
552 msgs = thrd.get_toplevel_messages()
554 if not first_toplevel:
555 if out_format.lower()=="json":
556 sys.stdout.write(", ")
558 first_toplevel = False
560 msgs.print_messages(out_format, 0, entire_thread)
562 if out_format.lower() == "json":
563 sys.stdout.write("]")
564 sys.stdout.write("\n")
566 #-------------------------------------
567 elif sys.argv[1] == 'reply':
569 if len(sys.argv) == 2:
570 #no search term. abort
571 print("Error: notmuch reply requires at least one search term.")
574 #mangle arguments wrapping terms with spaces in quotes
575 querystr = quote_query_line(sys.argv[2:])
576 msgs = Query(db,querystr).search_messages()
577 print (Notmuch().format_reply(msgs))
579 #-------------------------------------
580 elif sys.argv[1] == 'count':
581 if len(sys.argv) == 2:
582 #no further search term, count all
585 #mangle arguments wrapping terms with spaces in quotes
586 querystr = quote_query_line(sys.argv[2:])
587 print(Database().create_query(querystr).count_messages())
589 #-------------------------------------
590 elif sys.argv[1] == 'tag':
591 #build lists of tags to be added and removed
593 while not sys.argv[2]=='--' and \
594 (sys.argv[2].startswith('+') or sys.argv[2].startswith('-')):
595 if sys.argv[2].startswith('+'):
596 #append to add list without initial +
597 add.append(sys.argv.pop(2)[1:])
599 #append to remove list without initial -
600 remove.append(sys.argv.pop(2)[1:])
602 if sys.argv[2]=='--': sys.argv.pop(2)
603 #the rest is search terms
604 querystr = quote_query_line(sys.argv[2:])
605 db = Database(mode=Database.MODE.READ_WRITE)
606 m = Query(db,querystr).search_messages()
608 #actually add and remove all tags
609 map(msg.add_tag, add)
610 map(msg.remove_tag, remove)
611 #-------------------------------------
612 elif sys.argv[1] == 'search-tags':
613 if len(sys.argv) == 2:
614 #no further search term
615 print("\n".join(Database().get_all_tags()))
617 #mangle arguments wrapping terms with spaces in quotes
618 querystr = quote_query_line(sys.argv[2:])
620 m = Query(db,querystr).search_messages()
621 print("\n".join([t for t in m.collect_tags()]))
622 #-------------------------------------
623 elif sys.argv[1] == 'dump':
624 #TODO: implement "dump <filename>"
625 if len(sys.argv) == 2:
628 f = open(sys.argv[2],"w")
631 q.set_sort(Query.SORT.MESSAGE_ID)
632 m = q.search_messages()
634 f.write("%s (%s)\n" % (msg.get_message_id(), msg.get_tags()))
635 #-------------------------------------
636 elif sys.argv[1] == 'restore':
638 if len(sys.argv) == 2:
639 print("No filename given. Reading dump from stdin.")
642 f = open(sys.argv[2],"r")
643 #split the msg id and the tags
644 MSGID_TAGS = re.compile("(\S+)\s\((.*)\)$")
645 db = Database(mode=Database.MODE.READ_WRITE)
647 #read each line of the dump file
649 m = MSGID_TAGS.match(line)
651 sys.stderr.write("Warning: Ignoring invalid input line: %s" %
654 # split line in components and fetch message
656 new_tags= set(m.group(2).split())
657 msg = db.find_message(msg_id)
661 "Warning: Cannot apply tags to missing message: %s\n" % id)
664 #do nothing if the old set of tags is the same as the new one
665 old_tags = set(msg.get_tags())
666 if old_tags == new_tags: continue
670 #only remove tags if the new ones are not a superset anyway
671 if not (new_tags > old_tags): msg.remove_all_tags()
672 for tag in new_tags: msg.add_tag(tag)
675 #-------------------------------------
678 print "Error: Unknown command '%s' (see \"notmuch help\")" % sys.argv[1]
685 show <search-terms> [...]
686 reply [options...] <search-terms> [...]