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
26 from notmuch.filename import Filenames
31 import simplejson as json
34 #------------------------------------------------------------------------------
35 class Messages(object):
36 """Represents a list of notmuch messages
38 This object provides an iterator over a list of notmuch messages
39 (Technically, it provides a wrapper for the underlying
40 *notmuch_messages_t* structure). Do note that the underlying library
41 only provides a one-time iterator (it cannot reset the iterator to
42 the start). Thus iterating over the function will "exhaust" the list
43 of messages, and a subsequent iteration attempt will raise a
44 :exc:`NotmuchError` STATUS.NOT_INITIALIZED. Also note, that any
45 function that uses iteration will also exhaust the messages.If you
46 need to re-iterate over a list of messages you will need to retrieve
47 a new :class:`Messages` object or cache your :class:`Message`s in a
52 You can store and reuse the single Message objects as often as you
53 want as long as you keep the parent Messages object around. (Recall
54 that due to hierarchical memory allocation, all derived Message
55 objects will be invalid when we delete the parent Messages() object,
56 even if it was already "exhausted".) So this works::
59 msgs = Query(db,'').search_messages() #get a Messages() object
62 # msgs is "exhausted" now and even len(msgs) will raise an exception.
63 # However it will be kept around until all retrieved Message() objects are
64 # also deleted. If you did e.g. an explicit del(msgs) here, the
65 # following lines would fail.
67 # You can reiterate over *msglist* however as often as you want.
68 # It is simply a list with Message objects.
70 print (msglist[0].get_filename())
71 print (msglist[1].get_filename())
72 print (msglist[0].get_message_id())
76 _get = nmlib.notmuch_messages_get
77 _get.restype = c_void_p
79 _collect_tags = nmlib.notmuch_messages_collect_tags
80 _collect_tags.restype = c_void_p
82 def __init__(self, msgs_p, parent=None):
84 :param msgs_p: A pointer to an underlying *notmuch_messages_t*
85 structure. These are not publically exposed, so a user
86 will almost never instantiate a :class:`Messages` object
87 herself. They are usually handed back as a result,
88 e.g. in :meth:`Query.search_messages`. *msgs_p* must be
89 valid, we will raise an :exc:`NotmuchError`
90 (STATUS.NULL_POINTER) if it is `None`.
91 :type msgs_p: :class:`ctypes.c_void_p`
92 :param parent: The parent object
93 (ie :class:`Query`) these tags are derived from. It saves
94 a reference to it, so we can automatically delete the db
95 object once all derived objects are dead.
96 :TODO: Make the iterator work more than once and cache the tags in
100 NotmuchError(STATUS.NULL_POINTER)
103 #store parent, so we keep them alive as long as self is alive
104 self._parent = parent
106 def collect_tags(self):
107 """Return the unique :class:`Tags` in the contained messages
109 :returns: :class:`Tags`
110 :exceptions: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if not inited
112 .. note:: :meth:`collect_tags` will iterate over the messages and
113 therefore will not allow further iterations.
115 if self._msgs is None:
116 raise NotmuchError(STATUS.NOT_INITIALIZED)
118 # collect all tags (returns NULL on error)
119 tags_p = Messages._collect_tags (self._msgs)
120 #reset _msgs as we iterated over it and can do so only once
124 raise NotmuchError(STATUS.NULL_POINTER)
125 return Tags(tags_p, self)
128 """ Make Messages an iterator """
132 if self._msgs is None:
133 raise NotmuchError(STATUS.NOT_INITIALIZED)
135 if not nmlib.notmuch_messages_valid(self._msgs):
139 msg = Message(Messages._get (self._msgs), self)
140 nmlib.notmuch_messages_move_to_next(self._msgs)
143 def __nonzero__(self):
145 :return: True if there is at least one more thread in the
146 Iterator, False if not."""
147 return self._msgs is not None and \
148 nmlib.notmuch_messages_valid(self._msgs) > 0
151 """Close and free the notmuch Messages"""
152 if self._msgs is not None:
153 nmlib.notmuch_messages_destroy (self._msgs)
155 def print_messages(self, format, indent=0, entire_thread=False):
156 """Outputs messages as needed for 'notmuch show' to sys.stdout
158 :param format: A string of either 'text' or 'json'.
159 :param indent: A number indicating the reply depth of these messages.
160 :param entire_thread: A bool, indicating whether we want to output
161 whole threads or only the matching messages.
163 if format.lower() == "text":
167 elif format.lower() == "json":
176 sys.stdout.write(set_start)
178 # iterate through all toplevel messages in this thread
183 sys.stdout.write(set_sep)
186 sys.stdout.write(set_start)
187 match = msg.is_match()
190 if (match or entire_thread):
191 if format.lower() == "text":
192 sys.stdout.write(msg.format_message_as_text(indent))
193 elif format.lower() == "json":
194 sys.stdout.write(msg.format_message_as_json(indent))
197 next_indent = indent + 1
199 # get replies and print them also out (if there are any)
200 replies = msg.get_replies()
201 if not replies is None:
202 sys.stdout.write(set_sep)
203 replies.print_messages(format, next_indent, entire_thread)
205 sys.stdout.write(set_end)
206 sys.stdout.write(set_end)
208 #------------------------------------------------------------------------------
209 class Message(object):
210 """Represents a single Email message
212 Technically, this wraps the underlying *notmuch_message_t* structure.
215 """notmuch_message_get_filename (notmuch_message_t *message)"""
216 _get_filename = nmlib.notmuch_message_get_filename
217 _get_filename.restype = c_char_p
219 """return all filenames for a message"""
220 _get_filenames = nmlib.notmuch_message_get_filenames
221 _get_filenames.restype = c_void_p
223 """notmuch_message_get_flag"""
224 _get_flag = nmlib.notmuch_message_get_flag
225 _get_flag.restype = c_uint
227 """notmuch_message_get_message_id (notmuch_message_t *message)"""
228 _get_message_id = nmlib.notmuch_message_get_message_id
229 _get_message_id.restype = c_char_p
231 """notmuch_message_get_thread_id"""
232 _get_thread_id = nmlib.notmuch_message_get_thread_id
233 _get_thread_id.restype = c_char_p
235 """notmuch_message_get_replies"""
236 _get_replies = nmlib.notmuch_message_get_replies
237 _get_replies.restype = c_void_p
239 """notmuch_message_get_tags (notmuch_message_t *message)"""
240 _get_tags = nmlib.notmuch_message_get_tags
241 _get_tags.restype = c_void_p
243 _get_date = nmlib.notmuch_message_get_date
244 _get_date.restype = c_long
246 _get_header = nmlib.notmuch_message_get_header
247 _get_header.restype = c_char_p
249 #Constants: Flags that can be set/get with set_flag
250 FLAG = Enum(['MATCH'])
252 def __init__(self, msg_p, parent=None):
254 :param msg_p: A pointer to an internal notmuch_message_t
255 Structure. If it is `None`, we will raise an :exc:`NotmuchError`
257 :param parent: A 'parent' object is passed which this message is
258 derived from. We save a reference to it, so we can
259 automatically delete the parent object once all derived
263 NotmuchError(STATUS.NULL_POINTER)
265 #keep reference to parent, so we keep it alive
266 self._parent = parent
269 def get_message_id(self):
270 """Returns the message ID
272 :returns: String with a message ID
273 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
276 if self._msg is None:
277 raise NotmuchError(STATUS.NOT_INITIALIZED)
278 return Message._get_message_id(self._msg)
280 def get_thread_id(self):
281 """Returns the thread ID
283 The returned string belongs to 'message' will only be valid for as
284 long as the message is valid.
286 This function will not return None since Notmuch ensures that every
287 message belongs to a single thread.
289 :returns: String with a thread ID
290 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
293 if self._msg is None:
294 raise NotmuchError(STATUS.NOT_INITIALIZED)
296 return Message._get_thread_id (self._msg);
298 def get_replies(self):
299 """Gets all direct replies to this message as :class:`Messages` iterator
301 .. note:: This call only makes sense if 'message' was
302 ultimately obtained from a :class:`Thread` object, (such as
303 by coming directly from the result of calling
304 :meth:`Thread.get_toplevel_messages` or by any number of
305 subsequent calls to :meth:`get_replies`). If this message was
306 obtained through some non-thread means, (such as by a call
307 to :meth:`Query.search_messages`), then this function will
310 :returns: :class:`Messages` or `None` if there are no replies to
312 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
315 if self._msg is None:
316 raise NotmuchError(STATUS.NOT_INITIALIZED)
318 msgs_p = Message._get_replies(self._msg);
323 return Messages(msgs_p,self)
326 """Returns time_t of the message date
328 For the original textual representation of the Date header from the
329 message call notmuch_message_get_header() with a header value of
332 :returns: A time_t timestamp.
334 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
337 if self._msg is None:
338 raise NotmuchError(STATUS.NOT_INITIALIZED)
339 return Message._get_date(self._msg)
341 def get_header(self, header):
342 """Returns a message header
344 This returns any message header that is stored in the notmuch database.
345 This is only a selected subset of headers, which is currently:
347 TODO: add stored headers
349 :param header: The name of the header to be retrieved.
350 It is not case-sensitive (TODO: confirm).
352 :returns: The header value as string
353 :exception: :exc:`NotmuchError`
355 * STATUS.NOT_INITIALIZED if the message
357 * STATUS.NULL_POINTER, if no header was found
359 if self._msg is None:
360 raise NotmuchError(STATUS.NOT_INITIALIZED)
362 #Returns NULL if any error occurs.
363 header = Message._get_header (self._msg, header)
365 raise NotmuchError(STATUS.NULL_POINTER)
368 def get_filename(self):
369 """Returns the file path of the message file
371 :returns: Absolute file path & name of the message file
372 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
375 if self._msg is None:
376 raise NotmuchError(STATUS.NOT_INITIALIZED)
377 return Message._get_filename(self._msg)
379 def get_filenames(self):
380 """Get all filenames for the email corresponding to 'message'
382 Returns a Filenames() generator with all absolute filepaths for
383 messages recorded to have the same Message-ID. These files must
384 not necessarily have identical content."""
385 if self._msg is None:
386 raise NotmuchError(STATUS.NOT_INITIALIZED)
388 files_p = Message._get_filenames(self._msg)
390 return Filenames(files_p, self).as_generator()
392 def get_flag(self, flag):
393 """Checks whether a specific flag is set for this message
395 The method :meth:`Query.search_threads` sets
396 *Message.FLAG.MATCH* for those messages that match the
397 query. This method allows us to get the value of this flag.
399 :param flag: One of the :attr:`Message.FLAG` values (currently only
401 :returns: An unsigned int (0/1), indicating whether the flag is set.
402 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
405 if self._msg is None:
406 raise NotmuchError(STATUS.NOT_INITIALIZED)
407 return Message._get_flag(self._msg, flag)
409 def set_flag(self, flag, value):
410 """Sets/Unsets a specific flag for this message
412 :param flag: One of the :attr:`Message.FLAG` values (currently only
414 :param value: A bool indicating whether to set or unset the flag.
417 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
420 if self._msg is None:
421 raise NotmuchError(STATUS.NOT_INITIALIZED)
422 nmlib.notmuch_message_set_flag(self._msg, flag, value)
425 """Returns the message tags
427 :returns: A :class:`Tags` iterator.
428 :exception: :exc:`NotmuchError`
430 * STATUS.NOT_INITIALIZED if the message
432 * STATUS.NULL_POINTER, on error
434 if self._msg is None:
435 raise NotmuchError(STATUS.NOT_INITIALIZED)
437 tags_p = Message._get_tags(self._msg)
439 raise NotmuchError(STATUS.NULL_POINTER)
440 return Tags(tags_p, self)
442 def add_tag(self, tag):
443 """Adds a tag to the given message
445 Adds a tag to the current message. The maximal tag length is defined in
446 the notmuch library and is currently 200 bytes.
448 :param tag: String with a 'tag' to be added.
449 :returns: STATUS.SUCCESS if the tag was successfully added.
450 Raises an exception otherwise.
451 :exception: :exc:`NotmuchError`. They have the following meaning:
454 The 'tag' argument is NULL
456 The length of 'tag' is too long
457 (exceeds Message.NOTMUCH_TAG_MAX)
458 STATUS.READ_ONLY_DATABASE
459 Database was opened in read-only mode so message cannot be
461 STATUS.NOT_INITIALIZED
462 The message has not been initialized.
464 if self._msg is None:
465 raise NotmuchError(STATUS.NOT_INITIALIZED)
467 status = nmlib.notmuch_message_add_tag (self._msg, tag)
469 if STATUS.SUCCESS == status:
473 raise NotmuchError(status)
475 def remove_tag(self, tag):
476 """Removes a tag from the given message
478 If the message has no such tag, this is a non-operation and
479 will report success anyway.
481 :param tag: String with a 'tag' to be removed.
482 :returns: STATUS.SUCCESS if the tag was successfully removed or if
483 the message had no such tag.
484 Raises an exception otherwise.
485 :exception: :exc:`NotmuchError`. They have the following meaning:
488 The 'tag' argument is NULL
490 The length of 'tag' is too long
491 (exceeds NOTMUCH_TAG_MAX)
492 STATUS.READ_ONLY_DATABASE
493 Database was opened in read-only mode so message cannot
495 STATUS.NOT_INITIALIZED
496 The message has not been initialized.
498 if self._msg is None:
499 raise NotmuchError(STATUS.NOT_INITIALIZED)
501 status = nmlib.notmuch_message_remove_tag(self._msg, tag)
503 if STATUS.SUCCESS == status:
507 raise NotmuchError(status)
509 def remove_all_tags(self):
510 """Removes all tags from the given message.
512 See :meth:`freeze` for an example showing how to safely
515 :returns: STATUS.SUCCESS if the tags were successfully removed.
516 Raises an exception otherwise.
517 :exception: :exc:`NotmuchError`. They have the following meaning:
519 STATUS.READ_ONLY_DATABASE
520 Database was opened in read-only mode so message cannot
522 STATUS.NOT_INITIALIZED
523 The message has not been initialized.
525 if self._msg is None:
526 raise NotmuchError(STATUS.NOT_INITIALIZED)
528 status = nmlib.notmuch_message_remove_all_tags(self._msg)
530 if STATUS.SUCCESS == status:
534 raise NotmuchError(status)
537 """Freezes the current state of 'message' within the database
539 This means that changes to the message state, (via :meth:`add_tag`,
540 :meth:`remove_tag`, and :meth:`remove_all_tags`), will not be
541 committed to the database until the message is :meth:`thaw`ed.
543 Multiple calls to freeze/thaw are valid and these calls will
544 "stack". That is there must be as many calls to thaw as to freeze
545 before a message is actually thawed.
547 The ability to do freeze/thaw allows for safe transactions to
548 change tag values. For example, explicitly setting a message to
549 have a given set of tags might look like this::
552 msg.remove_all_tags()
557 With freeze/thaw used like this, the message in the database is
558 guaranteed to have either the full set of original tag values, or
559 the full set of new tag values, but nothing in between.
561 Imagine the example above without freeze/thaw and the operation
562 somehow getting interrupted. This could result in the message being
563 left with no tags if the interruption happened after
564 :meth:`remove_all_tags` but before :meth:`add_tag`.
566 :returns: STATUS.SUCCESS if the message was successfully frozen.
567 Raises an exception otherwise.
568 :exception: :exc:`NotmuchError`. They have the following meaning:
570 STATUS.READ_ONLY_DATABASE
571 Database was opened in read-only mode so message cannot
573 STATUS.NOT_INITIALIZED
574 The message has not been initialized.
576 if self._msg is None:
577 raise NotmuchError(STATUS.NOT_INITIALIZED)
579 status = nmlib.notmuch_message_freeze(self._msg)
581 if STATUS.SUCCESS == status:
585 raise NotmuchError(status)
588 """Thaws the current 'message'
590 Thaw the current 'message', synchronizing any changes that may have
591 occurred while 'message' was frozen into the notmuch database.
593 See :meth:`freeze` for an example of how to use this
594 function to safely provide tag changes.
596 Multiple calls to freeze/thaw are valid and these calls with
597 "stack". That is there must be as many calls to thaw as to freeze
598 before a message is actually thawed.
600 :returns: STATUS.SUCCESS if the message was successfully frozen.
601 Raises an exception otherwise.
602 :exception: :exc:`NotmuchError`. They have the following meaning:
604 STATUS.UNBALANCED_FREEZE_THAW
605 An attempt was made to thaw an unfrozen message.
606 That is, there have been an unbalanced number of calls
607 to :meth:`freeze` and :meth:`thaw`.
608 STATUS.NOT_INITIALIZED
609 The message has not been initialized.
611 if self._msg is None:
612 raise NotmuchError(STATUS.NOT_INITIALIZED)
614 status = nmlib.notmuch_message_thaw(self._msg)
616 if STATUS.SUCCESS == status:
620 raise NotmuchError(status)
624 """(Not implemented)"""
625 return self.get_flag(Message.FLAG.MATCH)
628 """A message() is represented by a 1-line summary"""
630 msg['from'] = self.get_header('from')
631 msg['tags'] = str(self.get_tags())
632 msg['date'] = date.fromtimestamp(self.get_date())
633 replies = self.get_replies()
634 msg['replies'] = len(replies) if replies is not None else -1
635 return "%(from)s (%(date)s) (%(tags)s) (%(replies)d) replies" % (msg)
638 def get_message_parts(self):
639 """Output like notmuch show"""
640 fp = open(self.get_filename())
641 email_msg = email.message_from_file(fp)
645 for msg in email_msg.walk():
646 if not msg.is_multipart():
650 def get_part(self, num):
651 """Returns the nth message body part"""
652 parts = self.get_message_parts()
653 if (num <= 0 or num > len(parts)):
656 out_part = parts[(num - 1)]
657 return out_part.get_payload(decode=True)
659 def format_message_internal(self):
660 """Create an internal representation of the message parts,
661 which can easily be output to json, text, or another output
662 format. The argument match tells whether this matched a
665 output["id"] = self.get_message_id()
666 output["match"] = self.is_match()
667 output["filename"] = self.get_filename()
668 output["tags"] = list(self.get_tags())
671 for h in ["Subject", "From", "To", "Cc", "Bcc", "Date"]:
672 headers[h] = self.get_header(h)
673 output["headers"] = headers
676 parts = self.get_message_parts()
677 for i in xrange(len(parts)):
680 part_dict["id"] = i + 1
681 # We'll be using this is a lot, so let's just get it once.
682 cont_type = msg.get_content_type()
683 part_dict["content-type"] = cont_type
685 # Now we emulate the current behaviour, where it ignores
686 # the html if there's a text representation.
688 # This is being worked on, but it will be easier to fix
689 # here in the future than to end up with another
690 # incompatible solution.
691 disposition = msg["Content-Disposition"]
692 if disposition and disposition.lower().startswith("attachment"):
693 part_dict["filename"] = msg.get_filename()
695 if cont_type.lower() == "text/plain":
696 part_dict["content"] = msg.get_payload()
697 elif (cont_type.lower() == "text/html" and
699 part_dict["content"] = msg.get_payload()
700 body.append(part_dict)
702 output["body"] = body
706 def format_message_as_json(self, indent=0):
707 """Outputs the message as json. This is essentially the same
708 as python's dict format, but we run it through, just so we
709 don't have to worry about the details."""
710 return json.dumps(self.format_message_internal())
712 def format_message_as_text(self, indent=0):
713 """Outputs it in the old-fashioned notmuch text form. Will be
714 easy to change to a new format when the format changes."""
716 format = self.format_message_internal()
717 output = "\fmessage{ id:%s depth:%d match:%d filename:%s" \
718 % (format['id'], indent, format['match'], format['filename'])
719 output += "\n\fheader{"
721 #Todo: this date is supposed to be prettified, as in the index.
722 output += "\n%s (%s) (" % (format["headers"]["From"],
723 format["headers"]["Date"])
724 output += ", ".join(format["tags"])
727 output += "\nSubject: %s" % format["headers"]["Subject"]
728 output += "\nFrom: %s" % format["headers"]["From"]
729 output += "\nTo: %s" % format["headers"]["To"]
730 if format["headers"]["Cc"]:
731 output += "\nCc: %s" % format["headers"]["Cc"]
732 if format["headers"]["Bcc"]:
733 output += "\nBcc: %s" % format["headers"]["Bcc"]
734 output += "\nDate: %s" % format["headers"]["Date"]
735 output += "\n\fheader}"
737 output += "\n\fbody{"
739 parts = format["body"]
740 parts.sort(key=lambda x: x['id'])
742 if not p.has_key("filename"):
743 output += "\n\fpart{ "
744 output += "ID: %d, Content-type: %s\n" % (p["id"],
746 if p.has_key("content"):
747 output += "\n%s\n" % p["content"]
749 output += "Non-text part: %s\n" % p["content-type"]
750 output += "\n\fpart}"
752 output += "\n\fattachment{ "
753 output += "ID: %d, Content-type:%s\n" % (p["id"],
755 output += "Attachment: %s\n" % p["filename"]
756 output += "\n\fattachment}\n"
758 output += "\n\fbody}\n"
759 output += "\n\fmessage}"
764 """Close and free the notmuch Message"""
765 if self._msg is not None:
766 nmlib.notmuch_message_destroy (self._msg)