2 This file is part of notmuch.
4 Notmuch is free software: you can redistribute it and/or modify it
5 under the terms of the GNU General Public License as published by the
6 Free Software Foundation, either version 3 of the License, or (at your
7 option) any later version.
9 Notmuch is distributed in the hope that it will be useful, but WITHOUT
10 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11 FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
14 You should have received a copy of the GNU General Public License
15 along with notmuch. If not, see <http://www.gnu.org/licenses/>.
17 Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de>'
18 Jesse Rosenthal <jrosenthal@jhu.edu>
22 from ctypes import c_char_p, c_void_p, c_long, c_uint
23 from datetime import date
24 from notmuch.globals import nmlib, STATUS, NotmuchError, Enum
25 from notmuch.tag import Tags
30 import simplejson as json
33 #------------------------------------------------------------------------------
34 class Messages(object):
35 """Represents a list of notmuch messages
37 This object provides an iterator over a list of notmuch messages
38 (Technically, it provides a wrapper for the underlying
39 *notmuch_messages_t* structure). Do note that the underlying
40 library only provides a one-time iterator (it cannot reset the
41 iterator to the start). Thus iterating over the function will
42 "exhaust" the list of messages, and a subsequent iteration attempt
43 will raise a :exc:`NotmuchError` STATUS.NOT_INITIALIZED. Also
44 note, that any function that uses iteration will also
45 exhaust the messages. So both::
47 for msg in msgs: print msg
51 number_of_msgs = len(msgs)
53 will "exhaust" the Messages. If you need to re-iterate over a list of
54 messages you will need to retrieve a new :class:`Messages` object.
56 Things are not as bad as it seems though, you can store and reuse
57 the single Message objects as often as you want as long as you
58 keep the parent Messages object around. (Recall that due to
59 hierarchical memory allocation, all derived Message objects will
60 be invalid when we delete the parent Messages() object, even if it
61 was already "exhausted".) So this works::
64 msgs = Query(db,'').search_messages() #get a Messages() object
69 # msgs is "exhausted" now and even len(msgs) will raise an exception.
70 # However it will be kept around until all retrieved Message() objects are
71 # also deleted. If you did e.g. an explicit del(msgs) here, the
72 # following lines would fail.
74 # You can reiterate over *msglist* however as often as you want.
75 # It is simply a list with Message objects.
77 print (msglist[0].get_filename())
78 print (msglist[1].get_filename())
79 print (msglist[0].get_message_id())
83 _get = nmlib.notmuch_messages_get
84 _get.restype = c_void_p
86 _collect_tags = nmlib.notmuch_messages_collect_tags
87 _collect_tags.restype = c_void_p
89 def __init__(self, msgs_p, parent=None):
91 :param msgs_p: A pointer to an underlying *notmuch_messages_t*
92 structure. These are not publically exposed, so a user
93 will almost never instantiate a :class:`Messages` object
94 herself. They are usually handed back as a result,
95 e.g. in :meth:`Query.search_messages`. *msgs_p* must be
96 valid, we will raise an :exc:`NotmuchError`
97 (STATUS.NULL_POINTER) if it is `None`.
98 :type msgs_p: :class:`ctypes.c_void_p`
99 :param parent: The parent object
100 (ie :class:`Query`) these tags are derived from. It saves
101 a reference to it, so we can automatically delete the db
102 object once all derived objects are dead.
103 :TODO: Make the iterator work more than once and cache the tags in
104 the Python object.(?)
107 NotmuchError(STATUS.NULL_POINTER)
110 #store parent, so we keep them alive as long as self is alive
111 self._parent = parent
113 def collect_tags(self):
114 """Return the unique :class:`Tags` in the contained messages
116 :returns: :class:`Tags`
117 :exceptions: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if not inited
119 .. note:: :meth:`collect_tags` will iterate over the messages and
120 therefore will not allow further iterations.
122 if self._msgs is None:
123 raise NotmuchError(STATUS.NOT_INITIALIZED)
125 # collect all tags (returns NULL on error)
126 tags_p = Messages._collect_tags (self._msgs)
127 #reset _msgs as we iterated over it and can do so only once
131 raise NotmuchError(STATUS.NULL_POINTER)
132 return Tags(tags_p, self)
135 """ Make Messages an iterator """
139 if self._msgs is None:
140 raise NotmuchError(STATUS.NOT_INITIALIZED)
142 if not nmlib.notmuch_messages_valid(self._msgs):
146 msg = Message(Messages._get (self._msgs), self)
147 nmlib.notmuch_messages_move_to_next(self._msgs)
151 """len(:class:`Messages`) returns the number of contained messages
153 .. note:: As this iterates over the messages, we will not be able to
154 iterate over them again! So this will fail::
157 msgs = Database().create_query('').search_message()
158 if len(msgs) > 0: #this 'exhausts' msgs
159 # next line raises NotmuchError(STATUS.NOT_INITIALIZED)!!!
160 for msg in msgs: print msg
162 Most of the time, using the
163 :meth:`Query.count_messages` is therefore more
164 appropriate (and much faster). While not guaranteeing
165 that it will return the exact same number than len(),
166 in my tests it effectively always did so.
168 if self._msgs is None:
169 raise NotmuchError(STATUS.NOT_INITIALIZED)
172 while nmlib.notmuch_messages_valid(self._msgs):
173 nmlib.notmuch_messages_move_to_next(self._msgs)
179 """Close and free the notmuch Messages"""
180 if self._msgs is not None:
181 nmlib.notmuch_messages_destroy (self._msgs)
183 def print_messages(self, format, indent=0, entire_thread=False):
184 """Outputs messages as needed for 'notmuch show' to sys.stdout
186 :param format: A string of either 'text' or 'json'.
187 :param indent: A number indicating the reply depth of these messages.
188 :param entire_thread: A bool, indicating whether we want to output
189 whole threads or only the matching messages.
191 if format.lower() == "text":
195 elif format.lower() == "json":
204 sys.stdout.write(set_start)
206 # iterate through all toplevel messages in this thread
211 sys.stdout.write(set_sep)
214 sys.stdout.write(set_start)
215 match = msg.is_match()
218 if (match or entire_thread):
219 if format.lower() == "text":
220 sys.stdout.write(msg.format_message_as_text(indent))
221 elif format.lower() == "json":
222 sys.stdout.write(msg.format_message_as_json(indent))
225 next_indent = indent + 1
227 # get replies and print them also out (if there are any)
228 replies = msg.get_replies()
229 if not replies is None:
230 sys.stdout.write(set_sep)
231 replies.print_messages(format, next_indent, entire_thread)
233 sys.stdout.write(set_end)
234 sys.stdout.write(set_end)
236 #------------------------------------------------------------------------------
237 class Message(object):
238 """Represents a single Email message
240 Technically, this wraps the underlying *notmuch_message_t* structure.
243 """notmuch_message_get_filename (notmuch_message_t *message)"""
244 _get_filename = nmlib.notmuch_message_get_filename
245 _get_filename.restype = c_char_p
247 """notmuch_message_get_flag"""
248 _get_flag = nmlib.notmuch_message_get_flag
249 _get_flag.restype = c_uint
251 """notmuch_message_get_message_id (notmuch_message_t *message)"""
252 _get_message_id = nmlib.notmuch_message_get_message_id
253 _get_message_id.restype = c_char_p
255 """notmuch_message_get_thread_id"""
256 _get_thread_id = nmlib.notmuch_message_get_thread_id
257 _get_thread_id.restype = c_char_p
259 """notmuch_message_get_replies"""
260 _get_replies = nmlib.notmuch_message_get_replies
261 _get_replies.restype = c_void_p
263 """notmuch_message_get_tags (notmuch_message_t *message)"""
264 _get_tags = nmlib.notmuch_message_get_tags
265 _get_tags.restype = c_void_p
267 _get_date = nmlib.notmuch_message_get_date
268 _get_date.restype = c_long
270 _get_header = nmlib.notmuch_message_get_header
271 _get_header.restype = c_char_p
273 #Constants: Flags that can be set/get with set_flag
274 FLAG = Enum(['MATCH'])
276 def __init__(self, msg_p, parent=None):
278 :param msg_p: A pointer to an internal notmuch_message_t
279 Structure. If it is `None`, we will raise an :exc:`NotmuchError`
281 :param parent: A 'parent' object is passed which this message is
282 derived from. We save a reference to it, so we can
283 automatically delete the parent object once all derived
287 NotmuchError(STATUS.NULL_POINTER)
289 #keep reference to parent, so we keep it alive
290 self._parent = parent
293 def get_message_id(self):
294 """Returns the message ID
296 :returns: String with a message ID
297 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
300 if self._msg is None:
301 raise NotmuchError(STATUS.NOT_INITIALIZED)
302 return Message._get_message_id(self._msg)
304 def get_thread_id(self):
305 """Returns the thread ID
307 The returned string belongs to 'message' will only be valid for as
308 long as the message is valid.
310 This function will not return None since Notmuch ensures that every
311 message belongs to a single thread.
313 :returns: String with a thread ID
314 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
317 if self._msg is None:
318 raise NotmuchError(STATUS.NOT_INITIALIZED)
320 return Message._get_thread_id (self._msg);
322 def get_replies(self):
323 """Gets all direct replies to this message as :class:`Messages` iterator
325 .. note:: This call only makes sense if 'message' was
326 ultimately obtained from a :class:`Thread` object, (such as
327 by coming directly from the result of calling
328 :meth:`Thread.get_toplevel_messages` or by any number of
329 subsequent calls to :meth:`get_replies`). If this message was
330 obtained through some non-thread means, (such as by a call
331 to :meth:`Query.search_messages`), then this function will
334 :returns: :class:`Messages` or `None` if there are no replies to
336 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
339 if self._msg is None:
340 raise NotmuchError(STATUS.NOT_INITIALIZED)
342 msgs_p = Message._get_replies(self._msg);
347 return Messages(msgs_p,self)
350 """Returns time_t of the message date
352 For the original textual representation of the Date header from the
353 message call notmuch_message_get_header() with a header value of
356 :returns: A time_t timestamp.
358 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
361 if self._msg is None:
362 raise NotmuchError(STATUS.NOT_INITIALIZED)
363 return Message._get_date(self._msg)
365 def get_header(self, header):
366 """Returns a message header
368 This returns any message header that is stored in the notmuch database.
369 This is only a selected subset of headers, which is currently:
371 TODO: add stored headers
373 :param header: The name of the header to be retrieved.
374 It is not case-sensitive (TODO: confirm).
376 :returns: The header value as string
377 :exception: :exc:`NotmuchError`
379 * STATUS.NOT_INITIALIZED if the message
381 * STATUS.NULL_POINTER, if no header was found
383 if self._msg is None:
384 raise NotmuchError(STATUS.NOT_INITIALIZED)
386 #Returns NULL if any error occurs.
387 header = Message._get_header (self._msg, header)
389 raise NotmuchError(STATUS.NULL_POINTER)
392 def get_filename(self):
393 """Returns the file path of the message file
395 :returns: Absolute file path & name of the message file
396 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
399 if self._msg is None:
400 raise NotmuchError(STATUS.NOT_INITIALIZED)
401 return Message._get_filename(self._msg)
403 def get_flag(self, flag):
404 """Checks whether a specific flag is set for this message
406 The method :meth:`Query.search_threads` sets
407 *Message.FLAG.MATCH* for those messages that match the
408 query. This method allows us to get the value of this flag.
410 :param flag: One of the :attr:`Message.FLAG` values (currently only
412 :returns: An unsigned int (0/1), indicating whether the flag is set.
413 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
416 if self._msg is None:
417 raise NotmuchError(STATUS.NOT_INITIALIZED)
418 return Message._get_flag(self._msg, flag)
420 def set_flag(self, flag, value):
421 """Sets/Unsets a specific flag for this message
423 :param flag: One of the :attr:`Message.FLAG` values (currently only
425 :param value: A bool indicating whether to set or unset the flag.
428 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
431 if self._msg is None:
432 raise NotmuchError(STATUS.NOT_INITIALIZED)
433 nmlib.notmuch_message_set_flag(self._msg, flag, value)
436 """Returns the message tags
438 :returns: A :class:`Tags` iterator.
439 :exception: :exc:`NotmuchError`
441 * STATUS.NOT_INITIALIZED if the message
443 * STATUS.NULL_POINTER, on error
445 if self._msg is None:
446 raise NotmuchError(STATUS.NOT_INITIALIZED)
448 tags_p = Message._get_tags(self._msg)
450 raise NotmuchError(STATUS.NULL_POINTER)
451 return Tags(tags_p, self)
453 def add_tag(self, tag):
454 """Adds a tag to the given message
456 Adds a tag to the current message. The maximal tag length is defined in
457 the notmuch library and is currently 200 bytes.
459 :param tag: String with a 'tag' to be added.
460 :returns: STATUS.SUCCESS if the tag was successfully added.
461 Raises an exception otherwise.
462 :exception: :exc:`NotmuchError`. They have the following meaning:
465 The 'tag' argument is NULL
467 The length of 'tag' is too long
468 (exceeds Message.NOTMUCH_TAG_MAX)
469 STATUS.READ_ONLY_DATABASE
470 Database was opened in read-only mode so message cannot be
472 STATUS.NOT_INITIALIZED
473 The message has not been initialized.
475 if self._msg is None:
476 raise NotmuchError(STATUS.NOT_INITIALIZED)
478 status = nmlib.notmuch_message_add_tag (self._msg, tag)
480 if STATUS.SUCCESS == status:
484 raise NotmuchError(status)
486 def remove_tag(self, tag):
487 """Removes a tag from the given message
489 If the message has no such tag, this is a non-operation and
490 will report success anyway.
492 :param tag: String with a 'tag' to be removed.
493 :returns: STATUS.SUCCESS if the tag was successfully removed or if
494 the message had no such tag.
495 Raises an exception otherwise.
496 :exception: :exc:`NotmuchError`. They have the following meaning:
499 The 'tag' argument is NULL
501 The length of 'tag' is too long
502 (exceeds NOTMUCH_TAG_MAX)
503 STATUS.READ_ONLY_DATABASE
504 Database was opened in read-only mode so message cannot
506 STATUS.NOT_INITIALIZED
507 The message has not been initialized.
509 if self._msg is None:
510 raise NotmuchError(STATUS.NOT_INITIALIZED)
512 status = nmlib.notmuch_message_remove_tag(self._msg, tag)
514 if STATUS.SUCCESS == status:
518 raise NotmuchError(status)
520 def remove_all_tags(self):
521 """Removes all tags from the given message.
523 See :meth:`freeze` for an example showing how to safely
526 :returns: STATUS.SUCCESS if the tags were successfully removed.
527 Raises an exception otherwise.
528 :exception: :exc:`NotmuchError`. They have the following meaning:
530 STATUS.READ_ONLY_DATABASE
531 Database was opened in read-only mode so message cannot
533 STATUS.NOT_INITIALIZED
534 The message has not been initialized.
536 if self._msg is None:
537 raise NotmuchError(STATUS.NOT_INITIALIZED)
539 status = nmlib.notmuch_message_remove_all_tags(self._msg)
541 if STATUS.SUCCESS == status:
545 raise NotmuchError(status)
548 """Freezes the current state of 'message' within the database
550 This means that changes to the message state, (via :meth:`add_tag`,
551 :meth:`remove_tag`, and :meth:`remove_all_tags`), will not be
552 committed to the database until the message is :meth:`thaw`ed.
554 Multiple calls to freeze/thaw are valid and these calls will
555 "stack". That is there must be as many calls to thaw as to freeze
556 before a message is actually thawed.
558 The ability to do freeze/thaw allows for safe transactions to
559 change tag values. For example, explicitly setting a message to
560 have a given set of tags might look like this::
563 msg.remove_all_tags()
568 With freeze/thaw used like this, the message in the database is
569 guaranteed to have either the full set of original tag values, or
570 the full set of new tag values, but nothing in between.
572 Imagine the example above without freeze/thaw and the operation
573 somehow getting interrupted. This could result in the message being
574 left with no tags if the interruption happened after
575 :meth:`remove_all_tags` but before :meth:`add_tag`.
577 :returns: STATUS.SUCCESS if the message was successfully frozen.
578 Raises an exception otherwise.
579 :exception: :exc:`NotmuchError`. They have the following meaning:
581 STATUS.READ_ONLY_DATABASE
582 Database was opened in read-only mode so message cannot
584 STATUS.NOT_INITIALIZED
585 The message has not been initialized.
587 if self._msg is None:
588 raise NotmuchError(STATUS.NOT_INITIALIZED)
590 status = nmlib.notmuch_message_freeze(self._msg)
592 if STATUS.SUCCESS == status:
596 raise NotmuchError(status)
599 """Thaws the current 'message'
601 Thaw the current 'message', synchronizing any changes that may have
602 occurred while 'message' was frozen into the notmuch database.
604 See :meth:`freeze` for an example of how to use this
605 function to safely provide tag changes.
607 Multiple calls to freeze/thaw are valid and these calls with
608 "stack". That is there must be as many calls to thaw as to freeze
609 before a message is actually thawed.
611 :returns: STATUS.SUCCESS if the message was successfully frozen.
612 Raises an exception otherwise.
613 :exception: :exc:`NotmuchError`. They have the following meaning:
615 STATUS.UNBALANCED_FREEZE_THAW
616 An attempt was made to thaw an unfrozen message.
617 That is, there have been an unbalanced number of calls
618 to :meth:`freeze` and :meth:`thaw`.
619 STATUS.NOT_INITIALIZED
620 The message has not been initialized.
622 if self._msg is None:
623 raise NotmuchError(STATUS.NOT_INITIALIZED)
625 status = nmlib.notmuch_message_thaw(self._msg)
627 if STATUS.SUCCESS == status:
631 raise NotmuchError(status)
635 """(Not implemented)"""
636 return self.get_flag(Message.FLAG.MATCH)
639 """A message() is represented by a 1-line summary"""
641 msg['from'] = self.get_header('from')
642 msg['tags'] = str(self.get_tags())
643 msg['date'] = date.fromtimestamp(self.get_date())
644 replies = self.get_replies()
645 msg['replies'] = len(replies) if replies is not None else -1
646 return "%(from)s (%(date)s) (%(tags)s) (%(replies)d) replies" % (msg)
649 def get_message_parts(self):
650 """Output like notmuch show"""
651 fp = open(self.get_filename())
652 email_msg = email.message_from_file(fp)
656 for msg in email_msg.walk():
657 if not msg.is_multipart():
661 def get_part(self, num):
662 """Returns the nth message body part"""
663 parts = self.get_message_parts()
664 if (num <= 0 or num > len(parts)):
667 out_part = parts[(num - 1)]
668 return out_part.get_payload(decode=True)
670 def format_message_internal(self):
671 """Create an internal representation of the message parts,
672 which can easily be output to json, text, or another output
673 format. The argument match tells whether this matched a
676 output["id"] = self.get_message_id()
677 output["match"] = self.is_match()
678 output["filename"] = self.get_filename()
679 output["tags"] = list(self.get_tags())
682 for h in ["Subject", "From", "To", "Cc", "Bcc", "Date"]:
683 headers[h] = self.get_header(h)
684 output["headers"] = headers
687 parts = self.get_message_parts()
688 for i in xrange(len(parts)):
691 part_dict["id"] = i + 1
692 # We'll be using this is a lot, so let's just get it once.
693 cont_type = msg.get_content_type()
694 part_dict["content-type"] = cont_type
696 # Now we emulate the current behaviour, where it ignores
697 # the html if there's a text representation.
699 # This is being worked on, but it will be easier to fix
700 # here in the future than to end up with another
701 # incompatible solution.
702 disposition = msg["Content-Disposition"]
703 if disposition and disposition.lower().startswith("attachment"):
704 part_dict["filename"] = msg.get_filename()
706 if cont_type.lower() == "text/plain":
707 part_dict["content"] = msg.get_payload()
708 elif (cont_type.lower() == "text/html" and
710 part_dict["content"] = msg.get_payload()
711 body.append(part_dict)
713 output["body"] = body
717 def format_message_as_json(self, indent=0):
718 """Outputs the message as json. This is essentially the same
719 as python's dict format, but we run it through, just so we
720 don't have to worry about the details."""
721 return json.dumps(self.format_message_internal())
723 def format_message_as_text(self, indent=0):
724 """Outputs it in the old-fashioned notmuch text form. Will be
725 easy to change to a new format when the format changes."""
727 format = self.format_message_internal()
728 output = "\fmessage{ id:%s depth:%d match:%d filename:%s" \
729 % (format['id'], indent, format['match'], format['filename'])
730 output += "\n\fheader{"
732 #Todo: this date is supposed to be prettified, as in the index.
733 output += "\n%s (%s) (" % (format["headers"]["From"],
734 format["headers"]["Date"])
735 output += ", ".join(format["tags"])
738 output += "\nSubject: %s" % format["headers"]["Subject"]
739 output += "\nFrom: %s" % format["headers"]["From"]
740 output += "\nTo: %s" % format["headers"]["To"]
741 if format["headers"]["Cc"]:
742 output += "\nCc: %s" % format["headers"]["Cc"]
743 if format["headers"]["Bcc"]:
744 output += "\nBcc: %s" % format["headers"]["Bcc"]
745 output += "\nDate: %s" % format["headers"]["Date"]
746 output += "\n\fheader}"
748 output += "\n\fbody{"
750 parts = format["body"]
751 parts.sort(key=lambda x: x['id'])
753 if not p.has_key("filename"):
754 output += "\n\fpart{ "
755 output += "ID: %d, Content-type: %s\n" % (p["id"],
757 if p.has_key("content"):
758 output += "\n%s\n" % p["content"]
760 output += "Non-text part: %s\n" % p["content-type"]
761 output += "\n\fpart}"
763 output += "\n\fattachment{ "
764 output += "ID: %d, Content-type:%s\n" % (p["id"],
766 output += "Attachment: %s\n" % p["filename"]
767 output += "\n\fattachment}\n"
769 output += "\n\fbody}\n"
770 output += "\n\fmessage}"
775 """Close and free the notmuch Message"""
776 if self._msg is not None:
777 nmlib.notmuch_message_destroy (self._msg)