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())
75 As Message() implements both __hash__() and __cmp__(), it is
76 possible to make sets out of Messages() and use set arithmetic::
78 s1, s2 = set(msgs1), set(msgs2)
85 _get = nmlib.notmuch_messages_get
86 _get.restype = c_void_p
88 _collect_tags = nmlib.notmuch_messages_collect_tags
89 _collect_tags.restype = c_void_p
91 def __init__(self, msgs_p, parent=None):
93 :param msgs_p: A pointer to an underlying *notmuch_messages_t*
94 structure. These are not publically exposed, so a user
95 will almost never instantiate a :class:`Messages` object
96 herself. They are usually handed back as a result,
97 e.g. in :meth:`Query.search_messages`. *msgs_p* must be
98 valid, we will raise an :exc:`NotmuchError`
99 (STATUS.NULL_POINTER) if it is `None`.
100 :type msgs_p: :class:`ctypes.c_void_p`
101 :param parent: The parent object
102 (ie :class:`Query`) these tags are derived from. It saves
103 a reference to it, so we can automatically delete the db
104 object once all derived objects are dead.
105 :TODO: Make the iterator work more than once and cache the tags in
106 the Python object.(?)
109 NotmuchError(STATUS.NULL_POINTER)
112 #store parent, so we keep them alive as long as self is alive
113 self._parent = parent
115 def collect_tags(self):
116 """Return the unique :class:`Tags` in the contained messages
118 :returns: :class:`Tags`
119 :exceptions: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if not inited
121 .. note:: :meth:`collect_tags` will iterate over the messages and
122 therefore will not allow further iterations.
124 if self._msgs is None:
125 raise NotmuchError(STATUS.NOT_INITIALIZED)
127 # collect all tags (returns NULL on error)
128 tags_p = Messages._collect_tags (self._msgs)
129 #reset _msgs as we iterated over it and can do so only once
133 raise NotmuchError(STATUS.NULL_POINTER)
134 return Tags(tags_p, self)
137 """ Make Messages an iterator """
141 if self._msgs is None:
142 raise NotmuchError(STATUS.NOT_INITIALIZED)
144 if not nmlib.notmuch_messages_valid(self._msgs):
148 msg = Message(Messages._get (self._msgs), self)
149 nmlib.notmuch_messages_move_to_next(self._msgs)
152 def __nonzero__(self):
154 :return: True if there is at least one more thread in the
155 Iterator, False if not."""
156 return self._msgs is not None and \
157 nmlib.notmuch_messages_valid(self._msgs) > 0
160 """Close and free the notmuch Messages"""
161 if self._msgs is not None:
162 nmlib.notmuch_messages_destroy (self._msgs)
164 def print_messages(self, format, indent=0, entire_thread=False):
165 """Outputs messages as needed for 'notmuch show' to sys.stdout
167 :param format: A string of either 'text' or 'json'.
168 :param indent: A number indicating the reply depth of these messages.
169 :param entire_thread: A bool, indicating whether we want to output
170 whole threads or only the matching messages.
172 if format.lower() == "text":
176 elif format.lower() == "json":
185 sys.stdout.write(set_start)
187 # iterate through all toplevel messages in this thread
192 sys.stdout.write(set_sep)
195 sys.stdout.write(set_start)
196 match = msg.is_match()
199 if (match or entire_thread):
200 if format.lower() == "text":
201 sys.stdout.write(msg.format_message_as_text(indent))
202 elif format.lower() == "json":
203 sys.stdout.write(msg.format_message_as_json(indent))
206 next_indent = indent + 1
208 # get replies and print them also out (if there are any)
209 replies = msg.get_replies()
210 if not replies is None:
211 sys.stdout.write(set_sep)
212 replies.print_messages(format, next_indent, entire_thread)
214 sys.stdout.write(set_end)
215 sys.stdout.write(set_end)
217 #------------------------------------------------------------------------------
218 class Message(object):
219 """Represents a single Email message
221 Technically, this wraps the underlying *notmuch_message_t* structure.
223 As this implements both __hash__() and __cmp__(), it is possible to
224 compare 2 Message objects with::
229 """notmuch_message_get_filename (notmuch_message_t *message)"""
230 _get_filename = nmlib.notmuch_message_get_filename
231 _get_filename.restype = c_char_p
233 """return all filenames for a message"""
234 _get_filenames = nmlib.notmuch_message_get_filenames
235 _get_filenames.restype = c_void_p
237 """notmuch_message_get_flag"""
238 _get_flag = nmlib.notmuch_message_get_flag
239 _get_flag.restype = c_uint
241 """notmuch_message_get_message_id (notmuch_message_t *message)"""
242 _get_message_id = nmlib.notmuch_message_get_message_id
243 _get_message_id.restype = c_char_p
245 """notmuch_message_get_thread_id"""
246 _get_thread_id = nmlib.notmuch_message_get_thread_id
247 _get_thread_id.restype = c_char_p
249 """notmuch_message_get_replies"""
250 _get_replies = nmlib.notmuch_message_get_replies
251 _get_replies.restype = c_void_p
253 """notmuch_message_get_tags (notmuch_message_t *message)"""
254 _get_tags = nmlib.notmuch_message_get_tags
255 _get_tags.restype = c_void_p
257 _get_date = nmlib.notmuch_message_get_date
258 _get_date.restype = c_long
260 _get_header = nmlib.notmuch_message_get_header
261 _get_header.restype = c_char_p
263 #Constants: Flags that can be set/get with set_flag
264 FLAG = Enum(['MATCH'])
266 def __init__(self, msg_p, parent=None):
268 :param msg_p: A pointer to an internal notmuch_message_t
269 Structure. If it is `None`, we will raise an :exc:`NotmuchError`
271 :param parent: A 'parent' object is passed which this message is
272 derived from. We save a reference to it, so we can
273 automatically delete the parent object once all derived
277 NotmuchError(STATUS.NULL_POINTER)
279 #keep reference to parent, so we keep it alive
280 self._parent = parent
283 def get_message_id(self):
284 """Returns the message ID
286 :returns: String with a message ID
287 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
290 if self._msg is None:
291 raise NotmuchError(STATUS.NOT_INITIALIZED)
292 return Message._get_message_id(self._msg)
294 def get_thread_id(self):
295 """Returns the thread ID
297 The returned string belongs to 'message' will only be valid for as
298 long as the message is valid.
300 This function will not return None since Notmuch ensures that every
301 message belongs to a single thread.
303 :returns: String with a thread ID
304 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
307 if self._msg is None:
308 raise NotmuchError(STATUS.NOT_INITIALIZED)
310 return Message._get_thread_id (self._msg);
312 def get_replies(self):
313 """Gets all direct replies to this message as :class:`Messages` iterator
315 .. note:: This call only makes sense if 'message' was
316 ultimately obtained from a :class:`Thread` object, (such as
317 by coming directly from the result of calling
318 :meth:`Thread.get_toplevel_messages` or by any number of
319 subsequent calls to :meth:`get_replies`). If this message was
320 obtained through some non-thread means, (such as by a call
321 to :meth:`Query.search_messages`), then this function will
324 :returns: :class:`Messages` or `None` if there are no replies to
326 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
329 if self._msg is None:
330 raise NotmuchError(STATUS.NOT_INITIALIZED)
332 msgs_p = Message._get_replies(self._msg);
337 return Messages(msgs_p,self)
340 """Returns time_t of the message date
342 For the original textual representation of the Date header from the
343 message call notmuch_message_get_header() with a header value of
346 :returns: A time_t timestamp.
348 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
351 if self._msg is None:
352 raise NotmuchError(STATUS.NOT_INITIALIZED)
353 return Message._get_date(self._msg)
355 def get_header(self, header):
356 """Returns a message header
358 This returns any message header that is stored in the notmuch database.
359 This is only a selected subset of headers, which is currently:
361 TODO: add stored headers
363 :param header: The name of the header to be retrieved.
364 It is not case-sensitive (TODO: confirm).
366 :returns: The header value as string
367 :exception: :exc:`NotmuchError`
369 * STATUS.NOT_INITIALIZED if the message
371 * STATUS.NULL_POINTER, if no header was found
373 if self._msg is None:
374 raise NotmuchError(STATUS.NOT_INITIALIZED)
376 #Returns NULL if any error occurs.
377 header = Message._get_header (self._msg, header)
379 raise NotmuchError(STATUS.NULL_POINTER)
382 def get_filename(self):
383 """Returns the file path of the message file
385 :returns: Absolute file path & name of the message file
386 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
389 if self._msg is None:
390 raise NotmuchError(STATUS.NOT_INITIALIZED)
391 return Message._get_filename(self._msg)
393 def get_filenames(self):
394 """Get all filenames for the email corresponding to 'message'
396 Returns a Filenames() generator with all absolute filepaths for
397 messages recorded to have the same Message-ID. These files must
398 not necessarily have identical content."""
399 if self._msg is None:
400 raise NotmuchError(STATUS.NOT_INITIALIZED)
402 files_p = Message._get_filenames(self._msg)
404 return Filenames(files_p, self).as_generator()
406 def get_flag(self, flag):
407 """Checks whether a specific flag is set for this message
409 The method :meth:`Query.search_threads` sets
410 *Message.FLAG.MATCH* for those messages that match the
411 query. This method allows us to get the value of this flag.
413 :param flag: One of the :attr:`Message.FLAG` values (currently only
415 :returns: An unsigned int (0/1), indicating whether the flag is set.
416 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
419 if self._msg is None:
420 raise NotmuchError(STATUS.NOT_INITIALIZED)
421 return Message._get_flag(self._msg, flag)
423 def set_flag(self, flag, value):
424 """Sets/Unsets a specific flag for this message
426 :param flag: One of the :attr:`Message.FLAG` values (currently only
428 :param value: A bool indicating whether to set or unset the flag.
431 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
434 if self._msg is None:
435 raise NotmuchError(STATUS.NOT_INITIALIZED)
436 nmlib.notmuch_message_set_flag(self._msg, flag, value)
439 """Returns the message tags
441 :returns: A :class:`Tags` iterator.
442 :exception: :exc:`NotmuchError`
444 * STATUS.NOT_INITIALIZED if the message
446 * STATUS.NULL_POINTER, on error
448 if self._msg is None:
449 raise NotmuchError(STATUS.NOT_INITIALIZED)
451 tags_p = Message._get_tags(self._msg)
453 raise NotmuchError(STATUS.NULL_POINTER)
454 return Tags(tags_p, self)
456 def add_tag(self, tag):
457 """Adds a tag to the given message
459 Adds a tag to the current message. The maximal tag length is defined in
460 the notmuch library and is currently 200 bytes.
462 :param tag: String with a 'tag' to be added.
463 :returns: STATUS.SUCCESS if the tag was successfully added.
464 Raises an exception otherwise.
465 :exception: :exc:`NotmuchError`. They have the following meaning:
468 The 'tag' argument is NULL
470 The length of 'tag' is too long
471 (exceeds Message.NOTMUCH_TAG_MAX)
472 STATUS.READ_ONLY_DATABASE
473 Database was opened in read-only mode so message cannot be
475 STATUS.NOT_INITIALIZED
476 The message has not been initialized.
478 if self._msg is None:
479 raise NotmuchError(STATUS.NOT_INITIALIZED)
481 status = nmlib.notmuch_message_add_tag (self._msg, tag)
483 if STATUS.SUCCESS == status:
487 raise NotmuchError(status)
489 def remove_tag(self, tag):
490 """Removes a tag from the given message
492 If the message has no such tag, this is a non-operation and
493 will report success anyway.
495 :param tag: String with a 'tag' to be removed.
496 :returns: STATUS.SUCCESS if the tag was successfully removed or if
497 the message had no such tag.
498 Raises an exception otherwise.
499 :exception: :exc:`NotmuchError`. They have the following meaning:
502 The 'tag' argument is NULL
504 The length of 'tag' is too long
505 (exceeds NOTMUCH_TAG_MAX)
506 STATUS.READ_ONLY_DATABASE
507 Database was opened in read-only mode so message cannot
509 STATUS.NOT_INITIALIZED
510 The message has not been initialized.
512 if self._msg is None:
513 raise NotmuchError(STATUS.NOT_INITIALIZED)
515 status = nmlib.notmuch_message_remove_tag(self._msg, tag)
517 if STATUS.SUCCESS == status:
521 raise NotmuchError(status)
523 def remove_all_tags(self):
524 """Removes all tags from the given message.
526 See :meth:`freeze` for an example showing how to safely
529 :returns: STATUS.SUCCESS if the tags were successfully removed.
530 Raises an exception otherwise.
531 :exception: :exc:`NotmuchError`. They have the following meaning:
533 STATUS.READ_ONLY_DATABASE
534 Database was opened in read-only mode so message cannot
536 STATUS.NOT_INITIALIZED
537 The message has not been initialized.
539 if self._msg is None:
540 raise NotmuchError(STATUS.NOT_INITIALIZED)
542 status = nmlib.notmuch_message_remove_all_tags(self._msg)
544 if STATUS.SUCCESS == status:
548 raise NotmuchError(status)
551 """Freezes the current state of 'message' within the database
553 This means that changes to the message state, (via :meth:`add_tag`,
554 :meth:`remove_tag`, and :meth:`remove_all_tags`), will not be
555 committed to the database until the message is :meth:`thaw`ed.
557 Multiple calls to freeze/thaw are valid and these calls will
558 "stack". That is there must be as many calls to thaw as to freeze
559 before a message is actually thawed.
561 The ability to do freeze/thaw allows for safe transactions to
562 change tag values. For example, explicitly setting a message to
563 have a given set of tags might look like this::
566 msg.remove_all_tags()
571 With freeze/thaw used like this, the message in the database is
572 guaranteed to have either the full set of original tag values, or
573 the full set of new tag values, but nothing in between.
575 Imagine the example above without freeze/thaw and the operation
576 somehow getting interrupted. This could result in the message being
577 left with no tags if the interruption happened after
578 :meth:`remove_all_tags` but before :meth:`add_tag`.
580 :returns: STATUS.SUCCESS if the message was successfully frozen.
581 Raises an exception otherwise.
582 :exception: :exc:`NotmuchError`. They have the following meaning:
584 STATUS.READ_ONLY_DATABASE
585 Database was opened in read-only mode so message cannot
587 STATUS.NOT_INITIALIZED
588 The message has not been initialized.
590 if self._msg is None:
591 raise NotmuchError(STATUS.NOT_INITIALIZED)
593 status = nmlib.notmuch_message_freeze(self._msg)
595 if STATUS.SUCCESS == status:
599 raise NotmuchError(status)
602 """Thaws the current 'message'
604 Thaw the current 'message', synchronizing any changes that may have
605 occurred while 'message' was frozen into the notmuch database.
607 See :meth:`freeze` for an example of how to use this
608 function to safely provide tag changes.
610 Multiple calls to freeze/thaw are valid and these calls with
611 "stack". That is there must be as many calls to thaw as to freeze
612 before a message is actually thawed.
614 :returns: STATUS.SUCCESS if the message was successfully frozen.
615 Raises an exception otherwise.
616 :exception: :exc:`NotmuchError`. They have the following meaning:
618 STATUS.UNBALANCED_FREEZE_THAW
619 An attempt was made to thaw an unfrozen message.
620 That is, there have been an unbalanced number of calls
621 to :meth:`freeze` and :meth:`thaw`.
622 STATUS.NOT_INITIALIZED
623 The message has not been initialized.
625 if self._msg is None:
626 raise NotmuchError(STATUS.NOT_INITIALIZED)
628 status = nmlib.notmuch_message_thaw(self._msg)
630 if STATUS.SUCCESS == status:
634 raise NotmuchError(status)
638 """(Not implemented)"""
639 return self.get_flag(Message.FLAG.MATCH)
642 """A message() is represented by a 1-line summary"""
644 msg['from'] = self.get_header('from')
645 msg['tags'] = str(self.get_tags())
646 msg['date'] = date.fromtimestamp(self.get_date())
647 replies = self.get_replies()
648 msg['replies'] = len(replies) if replies is not None else -1
649 return "%(from)s (%(date)s) (%(tags)s) (%(replies)d) replies" % (msg)
652 def get_message_parts(self):
653 """Output like notmuch show"""
654 fp = open(self.get_filename())
655 email_msg = email.message_from_file(fp)
659 for msg in email_msg.walk():
660 if not msg.is_multipart():
664 def get_part(self, num):
665 """Returns the nth message body part"""
666 parts = self.get_message_parts()
667 if (num <= 0 or num > len(parts)):
670 out_part = parts[(num - 1)]
671 return out_part.get_payload(decode=True)
673 def format_message_internal(self):
674 """Create an internal representation of the message parts,
675 which can easily be output to json, text, or another output
676 format. The argument match tells whether this matched a
679 output["id"] = self.get_message_id()
680 output["match"] = self.is_match()
681 output["filename"] = self.get_filename()
682 output["tags"] = list(self.get_tags())
685 for h in ["Subject", "From", "To", "Cc", "Bcc", "Date"]:
686 headers[h] = self.get_header(h)
687 output["headers"] = headers
690 parts = self.get_message_parts()
691 for i in xrange(len(parts)):
694 part_dict["id"] = i + 1
695 # We'll be using this is a lot, so let's just get it once.
696 cont_type = msg.get_content_type()
697 part_dict["content-type"] = cont_type
699 # Now we emulate the current behaviour, where it ignores
700 # the html if there's a text representation.
702 # This is being worked on, but it will be easier to fix
703 # here in the future than to end up with another
704 # incompatible solution.
705 disposition = msg["Content-Disposition"]
706 if disposition and disposition.lower().startswith("attachment"):
707 part_dict["filename"] = msg.get_filename()
709 if cont_type.lower() == "text/plain":
710 part_dict["content"] = msg.get_payload()
711 elif (cont_type.lower() == "text/html" and
713 part_dict["content"] = msg.get_payload()
714 body.append(part_dict)
716 output["body"] = body
720 def format_message_as_json(self, indent=0):
721 """Outputs the message as json. This is essentially the same
722 as python's dict format, but we run it through, just so we
723 don't have to worry about the details."""
724 return json.dumps(self.format_message_internal())
726 def format_message_as_text(self, indent=0):
727 """Outputs it in the old-fashioned notmuch text form. Will be
728 easy to change to a new format when the format changes."""
730 format = self.format_message_internal()
731 output = "\fmessage{ id:%s depth:%d match:%d filename:%s" \
732 % (format['id'], indent, format['match'], format['filename'])
733 output += "\n\fheader{"
735 #Todo: this date is supposed to be prettified, as in the index.
736 output += "\n%s (%s) (" % (format["headers"]["From"],
737 format["headers"]["Date"])
738 output += ", ".join(format["tags"])
741 output += "\nSubject: %s" % format["headers"]["Subject"]
742 output += "\nFrom: %s" % format["headers"]["From"]
743 output += "\nTo: %s" % format["headers"]["To"]
744 if format["headers"]["Cc"]:
745 output += "\nCc: %s" % format["headers"]["Cc"]
746 if format["headers"]["Bcc"]:
747 output += "\nBcc: %s" % format["headers"]["Bcc"]
748 output += "\nDate: %s" % format["headers"]["Date"]
749 output += "\n\fheader}"
751 output += "\n\fbody{"
753 parts = format["body"]
754 parts.sort(key=lambda x: x['id'])
756 if not p.has_key("filename"):
757 output += "\n\fpart{ "
758 output += "ID: %d, Content-type: %s\n" % (p["id"],
760 if p.has_key("content"):
761 output += "\n%s\n" % p["content"]
763 output += "Non-text part: %s\n" % p["content-type"]
764 output += "\n\fpart}"
766 output += "\n\fattachment{ "
767 output += "ID: %d, Content-type:%s\n" % (p["id"],
769 output += "Attachment: %s\n" % p["filename"]
770 output += "\n\fattachment}\n"
772 output += "\n\fbody}\n"
773 output += "\n\fmessage}"
778 """Implement hash(), so we can use Message() sets"""
779 file = self.get_filename()
784 def __cmp__(self, other):
785 """Implement cmp(), so we can compare Message()s
787 2 Messages are considered equal if they point to the same
788 Message-Id and if they point to the same file names."""
789 res = cmp(self.get_message_id(), other.get_message_id())
791 res = cmp(list(self.get_filenames()), list(other.get_filenames()))
795 """Close and free the notmuch Message"""
796 if self._msg is not None:
797 nmlib.notmuch_message_destroy (self._msg)