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. If you need to
45 re-iterate over a list of messages you will need to retrieve a new
46 :class:`Messages` object or cache your :class:`Message`s in a list
51 You can store and reuse the single :class:`Message` objects as often
52 as you want as long as you keep the parent :class:`Messages` object
53 around. (Due to hierarchical memory allocation, all derived
54 :class:`Message` objects will be invalid when we delete the parent
55 :class:`Messages` object, even if it was already exhausted.) So
59 msgs = Query(db,'').search_messages() #get a Messages() object
62 # msgs is "exhausted" now and msgs.next() will raise an exception.
63 # However it will be kept alive until all retrieved Message()
64 # objects are also deleted. If you do e.g. an explicit del(msgs)
65 # here, the following lines would fail.
67 # You can reiterate over *msglist* however as often as you want.
68 # It is simply a list with :class:`Message`s.
70 print (msglist[0].get_filename())
71 print (msglist[1].get_filename())
72 print (msglist[0].get_message_id())
75 As :class:`Message` implements both __hash__() and __cmp__(), it is
76 possible to make sets out of :class:`Messages` and use set
77 arithmetic (this happens in python and will of course be *much*
78 slower than redoing a proper query with the appropriate filters::
80 s1, s2 = set(msgs1), set(msgs2)
85 Be careful when using set arithmetic between message sets derived
86 from different Databases (ie the same database reopened after
87 messages have changed). If messages have added or removed associated
88 files in the meantime, it is possible that the same message would be
89 considered as a different object (as it points to a different file).
93 _get = nmlib.notmuch_messages_get
94 _get.restype = c_void_p
96 _collect_tags = nmlib.notmuch_messages_collect_tags
97 _collect_tags.restype = c_void_p
99 def __init__(self, msgs_p, parent=None):
101 :param msgs_p: A pointer to an underlying *notmuch_messages_t*
102 structure. These are not publically exposed, so a user
103 will almost never instantiate a :class:`Messages` object
104 herself. They are usually handed back as a result,
105 e.g. in :meth:`Query.search_messages`. *msgs_p* must be
106 valid, we will raise an :exc:`NotmuchError`
107 (STATUS.NULL_POINTER) if it is `None`.
108 :type msgs_p: :class:`ctypes.c_void_p`
109 :param parent: The parent object
110 (ie :class:`Query`) these tags are derived from. It saves
111 a reference to it, so we can automatically delete the db
112 object once all derived objects are dead.
113 :TODO: Make the iterator work more than once and cache the tags in
114 the Python object.(?)
117 NotmuchError(STATUS.NULL_POINTER)
120 #store parent, so we keep them alive as long as self is alive
121 self._parent = parent
123 def collect_tags(self):
124 """Return the unique :class:`Tags` in the contained messages
126 :returns: :class:`Tags`
127 :exceptions: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if not inited
129 .. note:: :meth:`collect_tags` will iterate over the messages and
130 therefore will not allow further iterations.
132 if self._msgs is None:
133 raise NotmuchError(STATUS.NOT_INITIALIZED)
135 # collect all tags (returns NULL on error)
136 tags_p = Messages._collect_tags (self._msgs)
137 #reset _msgs as we iterated over it and can do so only once
141 raise NotmuchError(STATUS.NULL_POINTER)
142 return Tags(tags_p, self)
145 """ Make Messages an iterator """
149 if self._msgs is None:
150 raise NotmuchError(STATUS.NOT_INITIALIZED)
152 if not nmlib.notmuch_messages_valid(self._msgs):
156 msg = Message(Messages._get (self._msgs), self)
157 nmlib.notmuch_messages_move_to_next(self._msgs)
160 def __nonzero__(self):
162 :return: True if there is at least one more thread in the
163 Iterator, False if not."""
164 return self._msgs is not None and \
165 nmlib.notmuch_messages_valid(self._msgs) > 0
168 """Close and free the notmuch Messages"""
169 if self._msgs is not None:
170 nmlib.notmuch_messages_destroy (self._msgs)
172 def print_messages(self, format, indent=0, entire_thread=False):
173 """Outputs messages as needed for 'notmuch show' to sys.stdout
175 :param format: A string of either 'text' or 'json'.
176 :param indent: A number indicating the reply depth of these messages.
177 :param entire_thread: A bool, indicating whether we want to output
178 whole threads or only the matching messages.
180 if format.lower() == "text":
184 elif format.lower() == "json":
193 sys.stdout.write(set_start)
195 # iterate through all toplevel messages in this thread
200 sys.stdout.write(set_sep)
203 sys.stdout.write(set_start)
204 match = msg.is_match()
207 if (match or entire_thread):
208 if format.lower() == "text":
209 sys.stdout.write(msg.format_message_as_text(indent))
210 elif format.lower() == "json":
211 sys.stdout.write(msg.format_message_as_json(indent))
214 next_indent = indent + 1
216 # get replies and print them also out (if there are any)
217 replies = msg.get_replies()
218 if not replies is None:
219 sys.stdout.write(set_sep)
220 replies.print_messages(format, next_indent, entire_thread)
222 sys.stdout.write(set_end)
223 sys.stdout.write(set_end)
225 #------------------------------------------------------------------------------
226 class Message(object):
227 """Represents a single Email message
229 Technically, this wraps the underlying *notmuch_message_t* structure.
231 As this implements __cmp__() it is possible to compare 2
232 :class:`Message`s with::
237 """notmuch_message_get_filename (notmuch_message_t *message)"""
238 _get_filename = nmlib.notmuch_message_get_filename
239 _get_filename.restype = c_char_p
241 """return all filenames for a message"""
242 _get_filenames = nmlib.notmuch_message_get_filenames
243 _get_filenames.restype = c_void_p
245 """notmuch_message_get_flag"""
246 _get_flag = nmlib.notmuch_message_get_flag
247 _get_flag.restype = c_uint
249 """notmuch_message_get_message_id (notmuch_message_t *message)"""
250 _get_message_id = nmlib.notmuch_message_get_message_id
251 _get_message_id.restype = c_char_p
253 """notmuch_message_get_thread_id"""
254 _get_thread_id = nmlib.notmuch_message_get_thread_id
255 _get_thread_id.restype = c_char_p
257 """notmuch_message_get_replies"""
258 _get_replies = nmlib.notmuch_message_get_replies
259 _get_replies.restype = c_void_p
261 """notmuch_message_get_tags (notmuch_message_t *message)"""
262 _get_tags = nmlib.notmuch_message_get_tags
263 _get_tags.restype = c_void_p
265 _get_date = nmlib.notmuch_message_get_date
266 _get_date.restype = c_long
268 _get_header = nmlib.notmuch_message_get_header
269 _get_header.restype = c_char_p
271 #Constants: Flags that can be set/get with set_flag
272 FLAG = Enum(['MATCH'])
274 def __init__(self, msg_p, parent=None):
276 :param msg_p: A pointer to an internal notmuch_message_t
277 Structure. If it is `None`, we will raise an :exc:`NotmuchError`
279 :param parent: A 'parent' object is passed which this message is
280 derived from. We save a reference to it, so we can
281 automatically delete the parent object once all derived
285 NotmuchError(STATUS.NULL_POINTER)
287 #keep reference to parent, so we keep it alive
288 self._parent = parent
291 def get_message_id(self):
292 """Returns the message ID
294 :returns: String with a message ID
295 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
298 if self._msg is None:
299 raise NotmuchError(STATUS.NOT_INITIALIZED)
300 return Message._get_message_id(self._msg)
302 def get_thread_id(self):
303 """Returns the thread ID
305 The returned string belongs to 'message' will only be valid for as
306 long as the message is valid.
308 This function will not return None since Notmuch ensures that every
309 message belongs to a single thread.
311 :returns: String with a thread ID
312 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
315 if self._msg is None:
316 raise NotmuchError(STATUS.NOT_INITIALIZED)
318 return Message._get_thread_id (self._msg);
320 def get_replies(self):
321 """Gets all direct replies to this message as :class:`Messages` iterator
323 .. note:: This call only makes sense if 'message' was
324 ultimately obtained from a :class:`Thread` object, (such as
325 by coming directly from the result of calling
326 :meth:`Thread.get_toplevel_messages` or by any number of
327 subsequent calls to :meth:`get_replies`). If this message was
328 obtained through some non-thread means, (such as by a call
329 to :meth:`Query.search_messages`), then this function will
332 :returns: :class:`Messages` or `None` if there are no replies to
334 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
337 if self._msg is None:
338 raise NotmuchError(STATUS.NOT_INITIALIZED)
340 msgs_p = Message._get_replies(self._msg);
345 return Messages(msgs_p,self)
348 """Returns time_t of the message date
350 For the original textual representation of the Date header from the
351 message call notmuch_message_get_header() with a header value of
354 :returns: A time_t timestamp.
356 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
359 if self._msg is None:
360 raise NotmuchError(STATUS.NOT_INITIALIZED)
361 return Message._get_date(self._msg)
363 def get_header(self, header):
364 """Returns a message header
366 This returns any message header that is stored in the notmuch database.
367 This is only a selected subset of headers, which is currently:
369 TODO: add stored headers
371 :param header: The name of the header to be retrieved.
372 It is not case-sensitive (TODO: confirm).
374 :returns: The header value as string
375 :exception: :exc:`NotmuchError`
377 * STATUS.NOT_INITIALIZED if the message
379 * STATUS.NULL_POINTER, if no header was found
381 if self._msg is None:
382 raise NotmuchError(STATUS.NOT_INITIALIZED)
384 #Returns NULL if any error occurs.
385 header = Message._get_header (self._msg, header)
387 raise NotmuchError(STATUS.NULL_POINTER)
390 def get_filename(self):
391 """Returns the file path of the message file
393 :returns: Absolute file path & name of the message file
394 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
397 if self._msg is None:
398 raise NotmuchError(STATUS.NOT_INITIALIZED)
399 return Message._get_filename(self._msg)
401 def get_filenames(self):
402 """Get all filenames for the email corresponding to 'message'
404 Returns a Filenames() generator with all absolute filepaths for
405 messages recorded to have the same Message-ID. These files must
406 not necessarily have identical content."""
407 if self._msg is None:
408 raise NotmuchError(STATUS.NOT_INITIALIZED)
410 files_p = Message._get_filenames(self._msg)
412 return Filenames(files_p, self).as_generator()
414 def get_flag(self, flag):
415 """Checks whether a specific flag is set for this message
417 The method :meth:`Query.search_threads` sets
418 *Message.FLAG.MATCH* for those messages that match the
419 query. This method allows us to get the value of this flag.
421 :param flag: One of the :attr:`Message.FLAG` values (currently only
423 :returns: An unsigned int (0/1), indicating whether the flag is set.
424 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
427 if self._msg is None:
428 raise NotmuchError(STATUS.NOT_INITIALIZED)
429 return Message._get_flag(self._msg, flag)
431 def set_flag(self, flag, value):
432 """Sets/Unsets a specific flag for this message
434 :param flag: One of the :attr:`Message.FLAG` values (currently only
436 :param value: A bool indicating whether to set or unset the flag.
439 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
442 if self._msg is None:
443 raise NotmuchError(STATUS.NOT_INITIALIZED)
444 nmlib.notmuch_message_set_flag(self._msg, flag, value)
447 """Returns the message tags
449 :returns: A :class:`Tags` iterator.
450 :exception: :exc:`NotmuchError`
452 * STATUS.NOT_INITIALIZED if the message
454 * STATUS.NULL_POINTER, on error
456 if self._msg is None:
457 raise NotmuchError(STATUS.NOT_INITIALIZED)
459 tags_p = Message._get_tags(self._msg)
461 raise NotmuchError(STATUS.NULL_POINTER)
462 return Tags(tags_p, self)
464 def add_tag(self, tag):
465 """Adds a tag to the given message
467 Adds a tag to the current message. The maximal tag length is defined in
468 the notmuch library and is currently 200 bytes.
470 :param tag: String with a 'tag' to be added.
471 :returns: STATUS.SUCCESS if the tag was successfully added.
472 Raises an exception otherwise.
473 :exception: :exc:`NotmuchError`. They have the following meaning:
476 The 'tag' argument is NULL
478 The length of 'tag' is too long
479 (exceeds Message.NOTMUCH_TAG_MAX)
480 STATUS.READ_ONLY_DATABASE
481 Database was opened in read-only mode so message cannot be
483 STATUS.NOT_INITIALIZED
484 The message has not been initialized.
486 if self._msg is None:
487 raise NotmuchError(STATUS.NOT_INITIALIZED)
489 status = nmlib.notmuch_message_add_tag (self._msg, tag)
491 if STATUS.SUCCESS == status:
495 raise NotmuchError(status)
497 def remove_tag(self, tag):
498 """Removes a tag from the given message
500 If the message has no such tag, this is a non-operation and
501 will report success anyway.
503 :param tag: String with a 'tag' to be removed.
504 :returns: STATUS.SUCCESS if the tag was successfully removed or if
505 the message had no such tag.
506 Raises an exception otherwise.
507 :exception: :exc:`NotmuchError`. They have the following meaning:
510 The 'tag' argument is NULL
512 The length of 'tag' is too long
513 (exceeds NOTMUCH_TAG_MAX)
514 STATUS.READ_ONLY_DATABASE
515 Database was opened in read-only mode so message cannot
517 STATUS.NOT_INITIALIZED
518 The message has not been initialized.
520 if self._msg is None:
521 raise NotmuchError(STATUS.NOT_INITIALIZED)
523 status = nmlib.notmuch_message_remove_tag(self._msg, tag)
525 if STATUS.SUCCESS == status:
529 raise NotmuchError(status)
531 def remove_all_tags(self):
532 """Removes all tags from the given message.
534 See :meth:`freeze` for an example showing how to safely
537 :returns: STATUS.SUCCESS if the tags were successfully removed.
538 Raises an exception otherwise.
539 :exception: :exc:`NotmuchError`. They have the following meaning:
541 STATUS.READ_ONLY_DATABASE
542 Database was opened in read-only mode so message cannot
544 STATUS.NOT_INITIALIZED
545 The message has not been initialized.
547 if self._msg is None:
548 raise NotmuchError(STATUS.NOT_INITIALIZED)
550 status = nmlib.notmuch_message_remove_all_tags(self._msg)
552 if STATUS.SUCCESS == status:
556 raise NotmuchError(status)
559 """Freezes the current state of 'message' within the database
561 This means that changes to the message state, (via :meth:`add_tag`,
562 :meth:`remove_tag`, and :meth:`remove_all_tags`), will not be
563 committed to the database until the message is :meth:`thaw`ed.
565 Multiple calls to freeze/thaw are valid and these calls will
566 "stack". That is there must be as many calls to thaw as to freeze
567 before a message is actually thawed.
569 The ability to do freeze/thaw allows for safe transactions to
570 change tag values. For example, explicitly setting a message to
571 have a given set of tags might look like this::
574 msg.remove_all_tags()
579 With freeze/thaw used like this, the message in the database is
580 guaranteed to have either the full set of original tag values, or
581 the full set of new tag values, but nothing in between.
583 Imagine the example above without freeze/thaw and the operation
584 somehow getting interrupted. This could result in the message being
585 left with no tags if the interruption happened after
586 :meth:`remove_all_tags` but before :meth:`add_tag`.
588 :returns: STATUS.SUCCESS if the message was successfully frozen.
589 Raises an exception otherwise.
590 :exception: :exc:`NotmuchError`. They have the following meaning:
592 STATUS.READ_ONLY_DATABASE
593 Database was opened in read-only mode so message cannot
595 STATUS.NOT_INITIALIZED
596 The message has not been initialized.
598 if self._msg is None:
599 raise NotmuchError(STATUS.NOT_INITIALIZED)
601 status = nmlib.notmuch_message_freeze(self._msg)
603 if STATUS.SUCCESS == status:
607 raise NotmuchError(status)
610 """Thaws the current 'message'
612 Thaw the current 'message', synchronizing any changes that may have
613 occurred while 'message' was frozen into the notmuch database.
615 See :meth:`freeze` for an example of how to use this
616 function to safely provide tag changes.
618 Multiple calls to freeze/thaw are valid and these calls with
619 "stack". That is there must be as many calls to thaw as to freeze
620 before a message is actually thawed.
622 :returns: STATUS.SUCCESS if the message was successfully frozen.
623 Raises an exception otherwise.
624 :exception: :exc:`NotmuchError`. They have the following meaning:
626 STATUS.UNBALANCED_FREEZE_THAW
627 An attempt was made to thaw an unfrozen message.
628 That is, there have been an unbalanced number of calls
629 to :meth:`freeze` and :meth:`thaw`.
630 STATUS.NOT_INITIALIZED
631 The message has not been initialized.
633 if self._msg is None:
634 raise NotmuchError(STATUS.NOT_INITIALIZED)
636 status = nmlib.notmuch_message_thaw(self._msg)
638 if STATUS.SUCCESS == status:
642 raise NotmuchError(status)
646 """(Not implemented)"""
647 return self.get_flag(Message.FLAG.MATCH)
650 """A message() is represented by a 1-line summary"""
652 msg['from'] = self.get_header('from')
653 msg['tags'] = str(self.get_tags())
654 msg['date'] = date.fromtimestamp(self.get_date())
655 replies = self.get_replies()
656 msg['replies'] = len(replies) if replies is not None else -1
657 return "%(from)s (%(date)s) (%(tags)s) (%(replies)d) replies" % (msg)
660 def get_message_parts(self):
661 """Output like notmuch show"""
662 fp = open(self.get_filename())
663 email_msg = email.message_from_file(fp)
667 for msg in email_msg.walk():
668 if not msg.is_multipart():
672 def get_part(self, num):
673 """Returns the nth message body part"""
674 parts = self.get_message_parts()
675 if (num <= 0 or num > len(parts)):
678 out_part = parts[(num - 1)]
679 return out_part.get_payload(decode=True)
681 def format_message_internal(self):
682 """Create an internal representation of the message parts,
683 which can easily be output to json, text, or another output
684 format. The argument match tells whether this matched a
687 output["id"] = self.get_message_id()
688 output["match"] = self.is_match()
689 output["filename"] = self.get_filename()
690 output["tags"] = list(self.get_tags())
693 for h in ["Subject", "From", "To", "Cc", "Bcc", "Date"]:
694 headers[h] = self.get_header(h)
695 output["headers"] = headers
698 parts = self.get_message_parts()
699 for i in xrange(len(parts)):
702 part_dict["id"] = i + 1
703 # We'll be using this is a lot, so let's just get it once.
704 cont_type = msg.get_content_type()
705 part_dict["content-type"] = cont_type
707 # Now we emulate the current behaviour, where it ignores
708 # the html if there's a text representation.
710 # This is being worked on, but it will be easier to fix
711 # here in the future than to end up with another
712 # incompatible solution.
713 disposition = msg["Content-Disposition"]
714 if disposition and disposition.lower().startswith("attachment"):
715 part_dict["filename"] = msg.get_filename()
717 if cont_type.lower() == "text/plain":
718 part_dict["content"] = msg.get_payload()
719 elif (cont_type.lower() == "text/html" and
721 part_dict["content"] = msg.get_payload()
722 body.append(part_dict)
724 output["body"] = body
728 def format_message_as_json(self, indent=0):
729 """Outputs the message as json. This is essentially the same
730 as python's dict format, but we run it through, just so we
731 don't have to worry about the details."""
732 return json.dumps(self.format_message_internal())
734 def format_message_as_text(self, indent=0):
735 """Outputs it in the old-fashioned notmuch text form. Will be
736 easy to change to a new format when the format changes."""
738 format = self.format_message_internal()
739 output = "\fmessage{ id:%s depth:%d match:%d filename:%s" \
740 % (format['id'], indent, format['match'], format['filename'])
741 output += "\n\fheader{"
743 #Todo: this date is supposed to be prettified, as in the index.
744 output += "\n%s (%s) (" % (format["headers"]["From"],
745 format["headers"]["Date"])
746 output += ", ".join(format["tags"])
749 output += "\nSubject: %s" % format["headers"]["Subject"]
750 output += "\nFrom: %s" % format["headers"]["From"]
751 output += "\nTo: %s" % format["headers"]["To"]
752 if format["headers"]["Cc"]:
753 output += "\nCc: %s" % format["headers"]["Cc"]
754 if format["headers"]["Bcc"]:
755 output += "\nBcc: %s" % format["headers"]["Bcc"]
756 output += "\nDate: %s" % format["headers"]["Date"]
757 output += "\n\fheader}"
759 output += "\n\fbody{"
761 parts = format["body"]
762 parts.sort(key=lambda x: x['id'])
764 if not p.has_key("filename"):
765 output += "\n\fpart{ "
766 output += "ID: %d, Content-type: %s\n" % (p["id"],
768 if p.has_key("content"):
769 output += "\n%s\n" % p["content"]
771 output += "Non-text part: %s\n" % p["content-type"]
772 output += "\n\fpart}"
774 output += "\n\fattachment{ "
775 output += "ID: %d, Content-type:%s\n" % (p["id"],
777 output += "Attachment: %s\n" % p["filename"]
778 output += "\n\fattachment}\n"
780 output += "\n\fbody}\n"
781 output += "\n\fmessage}"
786 """Implement hash(), so we can use Message() sets"""
787 file = self.get_filename()
792 def __cmp__(self, other):
793 """Implement cmp(), so we can compare Message()s
795 2 messages are considered equal if they point to the same
796 Message-Id and if they point to the same file names. If 2
797 Messages derive from different queries where some files have
798 been added or removed, the same messages would not be considered
799 equal (as they do not point to the same set of files
801 res = cmp(self.get_message_id(), other.get_message_id())
803 res = cmp(list(self.get_filenames()), list(other.get_filenames()))
807 """Close and free the notmuch Message"""
808 if self._msg is not None:
809 nmlib.notmuch_message_destroy (self._msg)