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, logging
14 from subprocess import call
15 from cnotmuch.notmuch import Database, Query
16 PREFIX=re.compile('(\w+):(.*$)')
17 #TODO Handle variable: NOTMUCH-CONFIG
19 #-------------------------------------------------------------------------
20 def get_user_email_addresses():
21 """ Reads a user's notmuch config and returns his email addresses as list (name, primary_address, other_address1,...)"""
23 from ConfigParser import SafeConfigParser
24 config = SafeConfigParser()
25 conf_f = os.getenv('NOTMUCH_CONFIG',
26 os.path.expanduser('~/.notmuch-config'))
28 if not config.has_option('user','name'): name = ""
29 else:name = config.get('user','name')
31 if not config.has_option('user','primary_email'): mail = ""
32 else:mail = config.get('user','primary_email')
34 if not config.has_option('user','other_email'): other = []
35 else:other = config.get('user','other_email').rstrip(';').split(';')
40 #-------------------------------------------------------------------------
41 HELPTEXT="""The notmuch mail system.
43 Usage: notmuch <command> [args...]
45 Where <command> and [args...] are as follows:
47 setup Interactively setup notmuch for first use.
51 Find and import new messages to the notmuch database.
53 search [options...] <search-terms> [...]
55 Search for messages matching the given search terms.
57 show <search-terms> [...]
59 Show all messages matching the search terms.
61 count <search-terms> [...]
63 Count messages matching the search terms.
65 reply [options...] <search-terms> [...]
67 Construct a reply template for a set of messages.
69 tag +<tag>|-<tag> [...] [--] <search-terms> [...]
71 Add/remove tags for all messages matching the search terms.
75 Create a plain-text dump of the tags for each message.
79 Restore the tags from the given dump file (see 'dump').
81 search-tags [<search-terms> [...] ]
83 List all tags found in the database or matching messages.
87 This message, or more detailed help for the named command.
89 Use "notmuch help <command>" for more details on each command.
90 And "notmuch help search-terms" for the common search-terms syntax.
92 #-------------------------------------------------------------------------
93 #TODO: replace the dynamic pieces
94 USAGE="""Notmuch is configured and appears to have a database. Excellent!
96 At this point you can start exploring the functionality of notmuch by
97 using commands such as:
99 notmuch search tag:inbox
101 notmuch search to:"Sebastian Spaeth"
103 notmuch search from:"Sebastian@SSpaeth.de"
105 notmuch search subject:"my favorite things"
107 See "notmuch help search" for more details.
109 You can also use "notmuch show" with any of the thread IDs resulting
110 from a search. Finally, you may want to explore using a more sophisticated
111 interface to notmuch such as the emacs interface implemented in notmuch.el
112 or any other interface described at http://notmuchmail.org
114 And don't forget to run "notmuch new" whenever new mail arrives.
116 Have fun, and may your inbox never have much mail.
118 #-------------------------------------------------------------------------
119 def quote_reply(oldbody ,date, from_address):
120 """Transform a mail body into a quote text starting with On blah, x wrote:
121 :param body: a str with a mail body
122 :returns: The new payload of the email.message()
124 from cStringIO import StringIO
126 #we get handed a string, wrap it in a file-like object
127 oldbody = StringIO(oldbody)
130 newbody.write("On %s, %s wrote:\n" % (date, from_address))
133 newbody.write("> " + line)
135 return newbody.getvalue()
137 def format_reply(msgs):
138 """Gets handed Messages() and displays the reply to them"""
142 f = open(msg.get_filename(),"r")
143 reply = email.message_from_file(f)
145 #handle the easy non-multipart case:
146 if not reply.is_multipart():
147 reply.set_payload(quote_reply(reply.get_payload(),
148 reply['date'],reply['from']))
150 #handle the tricky multipart case
152 """A string describing which nontext attachements have been deleted"""
154 """A list of payload indices to be deleted"""
156 payloads = reply.get_payload()
158 for i, part in enumerate(payloads):
160 mime_main = part.get_content_maintype()
161 if mime_main not in ['multipart', 'message', 'text']:
162 deleted += "Non-text part: %s\n" % (part.get_content_type())
163 payloads[i].set_payload("Non-text part: %s" % (part.get_content_type()))
164 payloads[i].set_type('text/plain')
165 delpayloads.append(i)
167 # payloads[i].set_payload("Text part: %s" % (part.get_content_type()))
168 payloads[i].set_payload(quote_reply(payloads[i].get_payload(),reply['date'],reply['from']))
171 # Delete those payloads that we don't need anymore
172 for i in reversed(sorted(delpayloads)):
175 #Back to single- and multipart handling
177 my_addresses = get_user_email_addresses()
179 # filter our email addresses from all to: cc: and bcc: fields
180 # if we find one of "my" addresses being used,
181 # it is stored in used_address
182 for header in ['To', 'CC', 'Bcc']:
183 if not header in reply:
184 #only handle fields that exist
186 addresses = email.utils.getaddresses(reply.get_all(header,[]))
188 for name, mail in addresses:
189 if mail in my_addresses[1:]:
190 used_address = email.utils.formataddr((my_addresses[0],mail))
192 purged_addr.append(email.utils.formataddr((name,mail)))
195 reply.replace_header(header, ", ".join(purged_addr))
197 #we deleted all addresses, delete the header
200 # Use our primary email address to the From
201 # (save original from line, we still need it)
202 orig_from = reply['From']
204 reply['From'] = used_address if used_address \
205 else email.utils.formataddr((my_addresses[0],my_addresses[1]))
207 #reinsert the Subject after the From
208 orig_subject = reply['Subject']
210 reply['Subject'] = 'Re: ' + orig_subject
212 # Calculate our new To: field
214 # add all remaining original 'To' addresses
216 new_to += ", " + reply['To']
218 reply.add_header('To', new_to)
220 # Add our primary email address to the BCC
221 new_bcc = my_addresses[1]
222 if reply.has_key('Bcc'):
223 new_bcc += ', ' + reply['Bcc']
225 reply['Bcc'] = new_bcc
227 # Set replies 'In-Reply-To' header to original's Message-ID
228 if reply.has_key('Message-ID') :
229 del reply['In-Reply-To']
230 reply['In-Reply-To'] = reply['Message-ID']
232 #Add original's Message-ID to replies 'References' header.
233 if reply.has_key('References'):
234 ref = reply['References'] + ' ' +reply['Message-ID']
236 ref = reply['Message-ID']
237 del reply['References']
238 reply['References'] = ref
240 # Delete the original Message-ID.
241 del(reply['Message-ID'])
243 # filter all existing headers but a few and delete them from 'reply'
244 delheaders = filter(lambda x: x not in ['From','To','Subject','CC','Bcc',
245 'In-Reply-To', 'References',
246 'Content-Type'],reply.keys())
247 map(reply.__delitem__, delheaders)
250 From: Sebastian Spaeth <Sebastian@SSpaeth.de>
251 Subject: Re: Template =?iso-8859-1?b?Zvxy?= das Kochrezept
252 In-Reply-To: <4A6D55F9.6040405@SSpaeth.de>
253 References: <4A6D55F9.6040405@SSpaeth.de>
255 #return without Unixfrom
256 return reply.as_string(False)
257 #-------------------------------------------------------------------------
258 def quote_query_line(argv):
259 #mangle arguments wrapping terms with spaces in quotes
260 for i in xrange(0,len(argv)):
261 if argv[i].find(' ') >= 0:
262 #if we use prefix:termWithSpaces, put quotes around term
263 m = PREFIX.match(argv[i])
265 argv[i] = '%s:"%s"' % (m.group(1), m.group(2))
267 argv[i] = '"'+argv[i]+'"'
268 return ' '.join(argv)
270 if __name__ == '__main__':
272 # Handle command line options
273 #-------------------------------------
274 # No option given, print USAGE and exit
275 if len(sys.argv) == 1:
277 #-------------------------------------
278 elif sys.argv[1] == 'setup':
279 """Interactively setup notmuch for first use."""
280 print "Not implemented."
281 #-------------------------------------
282 elif sys.argv[1] == 'new':
283 """ Interactively setup notmuch for first use. """
284 #print "Not implemented. We cheat by calling the proper notmuch"
285 call(['notmuch new'],shell=True)
286 #-------------------------------------
287 elif sys.argv[1] == 'help':
288 if len(sys.argv) == 2: print HELPTEXT
289 else: print "Not implemented"
290 #-------------------------------------
291 elif sys.argv[1] == 'part':
295 first_search_term = None
296 for (i, arg) in enumerate(sys.argv[1:]):
297 if arg.startswith('--part='):
298 part_num_str=arg.split("=")[1]
300 part_num = int(part_num_str)
302 # just emulating behavior
304 elif not arg.startswith('--'):
305 #save the position of the first sys.argv that is a search term
306 first_search_term = i+1
308 if first_search_term:
309 #mangle arguments wrapping terms with spaces in quotes
310 querystr = quote_query_line(sys.argv[first_search_term:])
313 logging.debug("part "+querystr)
314 qry = Query(db,querystr)
315 msgs = qry.search_messages()
320 if len(msg_list) == 0:
322 elif len(msg_list) > 1:
323 raise Exception("search term did not match precisely one message")
326 print(msg.get_part(part_num))
327 #-------------------------------------
328 elif sys.argv[1] == 'search':
331 sort_order="newest-first"
332 first_search_term = None
333 for (i, arg) in enumerate(sys.argv[1:]):
334 if arg.startswith('--sort='):
335 sort_order=arg.split("=")[1]
336 if not sort_order in ("oldest-first", "newest-first"):
337 raise Exception("unknown sort order")
338 elif not arg.startswith('--'):
339 #save the position of the first sys.argv that is a search term
340 first_search_term = i+1
342 if first_search_term:
343 #mangle arguments wrapping terms with spaces in quotes
344 querystr = quote_query_line(sys.argv[first_search_term:])
347 logging.debug("search "+querystr)
348 qry = Query(db,querystr)
349 if sort_order == "oldest-first":
350 qry.set_sort(Query.SORT.OLDEST_FIRST)
352 qry.set_sort(Query.SORT.NEWEST_FIRST)
353 t = qry.search_threads()
358 #-------------------------------------
359 elif sys.argv[1] == 'show':
360 entire_thread = False
364 first_search_term = None
366 #ugly homegrown option parsing
367 #TODO: use OptionParser
368 for (i, arg) in enumerate(sys.argv[1:]):
369 if arg == '--entire-thread':
371 elif arg.startswith("--format="):
372 out_format = arg.split("=")[1]
373 if out_format == 'json':
374 #for compatibility use --entire-thread for json
376 if not out_format in ("json", "text"):
377 raise Exception("unknown format")
378 elif not arg.startswith('--'):
379 #save the position of the first sys.argv that is a search term
380 first_search_term = i+1
382 if first_search_term:
383 #mangle arguments wrapping terms with spaces in quotes
384 querystr = quote_query_line(sys.argv[first_search_term:])
386 logging.debug("show "+querystr)
387 t = Query(db,querystr).search_threads()
390 if out_format.lower()=="json":
391 sys.stdout.write("[")
394 msgs = thrd.get_toplevel_messages()
396 if not first_toplevel:
397 if out_format.lower()=="json":
398 sys.stdout.write(", ")
400 first_toplevel = False
402 msgs.print_messages(out_format, 0, entire_thread)
404 if out_format.lower() == "json":
405 sys.stdout.write("]")
406 sys.stdout.write("\n")
408 #-------------------------------------
409 elif sys.argv[1] == 'reply':
411 if len(sys.argv) == 2:
412 #no search term. abort
413 print("Error: notmuch reply requires at least one search term.")
416 #mangle arguments wrapping terms with spaces in quotes
417 querystr = quote_query_line(sys.argv[2:])
418 logging.debug("reply "+querystr)
419 msgs = Query(db,querystr).search_messages()
420 print (format_reply(msgs))
422 #-------------------------------------
423 elif sys.argv[1] == 'count':
424 if len(sys.argv) == 2:
425 #no further search term, count all
428 #mangle arguments wrapping terms with spaces in quotes
429 querystr = quote_query_line(sys.argv[2:])
430 print(Database().create_query(querystr).count_messages())
432 #-------------------------------------
433 elif sys.argv[1] == 'tag':
434 #build lists of tags to be added and removed
436 while not sys.argv[2]=='--' and \
437 (sys.argv[2].startswith('+') or sys.argv[2].startswith('-')):
438 if sys.argv[2].startswith('+'):
439 #append to add list without initial +
440 add.append(sys.argv.pop(2)[1:])
442 #append to remove list without initial -
443 remove.append(sys.argv.pop(2)[1:])
445 if sys.argv[2]=='--': sys.argv.pop(2)
446 #the rest is search terms
447 querystr = quote_query_line(sys.argv[2:])
448 logging.debug("tag search-term "+querystr)
449 db = Database(mode=Database.MODE.READ_WRITE)
450 m = Query(db,querystr).search_messages()
452 #actually add and remove all tags
453 map(msg.add_tag, add)
454 map(msg.remove_tag, remove)
455 #-------------------------------------
456 elif sys.argv[1] == 'search-tags':
457 if len(sys.argv) == 2:
458 #no further search term
459 print("\n".join(Database().get_all_tags()))
461 #mangle arguments wrapping terms with spaces in quotes
462 querystr = quote_query_line(sys.argv[2:])
463 logging.debug("search-term "+querystr)
465 m = Query(db,querystr).search_messages()
466 print("\n".join([t for t in m.collect_tags()]))
467 #-------------------------------------
468 elif sys.argv[1] == 'dump':
469 #TODO: implement "dump <filename>"
470 if len(sys.argv) == 2:
473 f = open(sys.argv[2],"w")
476 q.set_sort(Query.SORT.MESSAGE_ID)
477 m = q.search_messages()
479 f.write("%s (%s)\n" % (msg.get_message_id(), msg.get_tags()))
480 #-------------------------------------
481 elif sys.argv[1] == 'restore':
483 if len(sys.argv) == 2:
484 print("No filename given. Reading dump from stdin.")
487 f = open(sys.argv[2],"r")
488 #split the msg id and the tags
489 MSGID_TAGS = re.compile("(\S+)\s\((.*)\)$")
490 db = Database(mode=Database.MODE.READ_WRITE)
492 #read each line of the dump file
494 m = MSGID_TAGS.match(line)
496 sys.stderr.write("Warning: Ignoring invalid input line: %s" %
499 # split line in components and fetch message
501 new_tags= set(m.group(2).split())
502 msg = db.find_message(msg_id)
506 "Warning: Cannot apply tags to missing message: %s\n" % id)
509 #do nothing if the old set of tags is the same as the new one
510 old_tags = set(msg.get_tags())
511 if old_tags == new_tags: continue
515 #only remove tags if the new ones are not a superset anyway
516 if not (new_tags > old_tags): msg.remove_all_tags()
517 for tag in new_tags: msg.add_tag(tag)
520 #-------------------------------------
523 print "Error: Unknown command '%s' (see \"notmuch help\")" % sys.argv[1]
530 show <search-terms> [...]
531 reply [options...] <search-terms> [...]