1 from ctypes import c_char_p, c_void_p, c_long, c_bool
2 from datetime import date
3 from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum
4 from cnotmuch.tag import Tags
9 import simplejson as json
12 #------------------------------------------------------------------------------
13 class Messages(object):
14 """Represents a list of notmuch messages
16 This object provides an iterator over a list of notmuch messages
17 (Technically, it provides a wrapper for the underlying
18 *notmuch_messages_t* structure). Do note that the underlying
19 library only provides a one-time iterator (it cannot reset the
20 iterator to the start). Thus iterating over the function will
21 "exhaust" the list of messages, and a subsequent iteration attempt
22 will raise a :exc:`NotmuchError` STATUS.NOT_INITIALIZED. Also
23 note, that any function that uses iteration will also
24 exhaust the messages. So both::
26 for msg in msgs: print msg
30 number_of_msgs = len(msgs)
32 will "exhaust" the Messages. If you need to re-iterate over a list of
33 messages you will need to retrieve a new :class:`Messages` object.
35 Things are not as bad as it seems though, you can store and reuse
36 the single Message objects as often as you want as long as you
37 keep the parent Messages object around. (Recall that due to
38 hierarchical memory allocation, all derived Message objects will
39 be invalid when we delete the parent Messages() object, even if it
40 was already "exhausted".) So this works::
43 msgs = Query(db,'').search_messages() #get a Messages() object
48 # msgs is "exhausted" now and even len(msgs) will raise an exception.
49 # However it will be kept around until all retrieved Message() objects are
50 # also deleted. If you did e.g. an explicit del(msgs) here, the
51 # following lines would fail.
53 # You can reiterate over *msglist* however as often as you want.
54 # It is simply a list with Message objects.
56 print (msglist[0].get_filename())
57 print (msglist[1].get_filename())
58 print (msglist[0].get_message_id())
62 _get = nmlib.notmuch_messages_get
63 _get.restype = c_void_p
65 _collect_tags = nmlib.notmuch_messages_collect_tags
66 _collect_tags.restype = c_void_p
68 def __init__(self, msgs_p, parent=None):
70 :param msgs_p: A pointer to an underlying *notmuch_messages_t*
71 structure. These are not publically exposed, so a user
72 will almost never instantiate a :class:`Messages` object
73 herself. They are usually handed back as a result,
74 e.g. in :meth:`Query.search_messages`. *msgs_p* must be
75 valid, we will raise an :exc:`NotmuchError`
76 (STATUS.NULL_POINTER) if it is `None`.
77 :type msgs_p: :class:`ctypes.c_void_p`
78 :param parent: The parent object
79 (ie :class:`Query`) these tags are derived from. It saves
80 a reference to it, so we can automatically delete the db
81 object once all derived objects are dead.
82 :TODO: Make the iterator work more than once and cache the tags in
86 NotmuchError(STATUS.NULL_POINTER)
89 #store parent, so we keep them alive as long as self is alive
92 def collect_tags(self):
93 """Return the unique :class:`Tags` in the contained messages
95 :returns: :class:`Tags`
96 :exceptions: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if not inited
98 .. note:: :meth:`collect_tags` will iterate over the messages and
99 therefore will not allow further iterations.
101 if self._msgs is None:
102 raise NotmuchError(STATUS.NOT_INITIALIZED)
104 # collect all tags (returns NULL on error)
105 tags_p = Messages._collect_tags (self._msgs)
106 #reset _msgs as we iterated over it and can do so only once
110 raise NotmuchError(STATUS.NULL_POINTER)
111 return Tags(tags_p, self)
114 """ Make Messages an iterator """
118 if self._msgs is None:
119 raise NotmuchError(STATUS.NOT_INITIALIZED)
121 if not nmlib.notmuch_messages_valid(self._msgs):
125 msg = Message(Messages._get (self._msgs), self)
126 nmlib.notmuch_messages_move_to_next(self._msgs)
130 """len(:class:`Messages`) returns the number of contained messages
132 .. note:: As this iterates over the messages, we will not be able to
133 iterate over them again! So this will fail::
136 msgs = Database().create_query('').search_message()
137 if len(msgs) > 0: #this 'exhausts' msgs
138 # next line raises NotmuchError(STATUS.NOT_INITIALIZED)!!!
139 for msg in msgs: print msg
141 Most of the time, using the
142 :meth:`Query.count_messages` is therefore more
143 appropriate (and much faster). While not guaranteeing
144 that it will return the exact same number than len(),
145 in my tests it effectively always did so.
147 if self._msgs is None:
148 raise NotmuchError(STATUS.NOT_INITIALIZED)
151 while nmlib.notmuch_messages_valid(self._msgs):
152 nmlib.notmuch_messages_move_to_next(self._msgs)
160 """Close and free the notmuch Messages"""
161 if self._msgs is not None:
162 nmlib.notmuch_messages_destroy (self._msgs)
164 def show_messages(self, format, indent=0, entire_thread=True):
165 if format.lower() == "text":
169 elif format.lower() == "json":
178 sys.stdout.write(set_start)
184 sys.stdout.write(set_sep)
187 sys.stdout.write(set_start)
188 match = msg.is_match()
191 if (match or entire_thread):
192 if format.lower() == "text":
193 sys.stdout.write(msg.format_message_as_text(indent))
194 elif format.lower() == "json":
195 sys.stdout.write(msg.format_message_as_json(indent))
198 next_indent = indent + 1
201 replies = msg.get_replies()
202 # if isinstance(replies, types.NoneType):
204 if not replies is None:
205 sys.stdout.write(set_sep)
206 replies.show_messages(format, next_indent, entire_thread)
209 sys.stdout.write(set_end)
210 sys.stdout.write(set_end)
212 #------------------------------------------------------------------------------
213 class Message(object):
214 """Represents a single Email message
216 Technically, this wraps the underlying *notmuch_message_t* structure.
219 """notmuch_message_get_filename (notmuch_message_t *message)"""
220 _get_filename = nmlib.notmuch_message_get_filename
221 _get_filename.restype = c_char_p
223 """notmuch_message_get_flag"""
224 _get_flag = nmlib.notmuch_message_get_flag
225 _get_flag.restype = c_bool
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_flag(self, flag):
380 """Checks whether a specific flag is set for this message
382 The method :meth:`Query.search_threads` sets
383 *Message.FLAG.MATCH* for those messages that match the
384 query. This method allows us to get the value of this flag.
386 :param flag: One of the :attr:`Message.FLAG` values (currently only
388 :returns: A bool, indicating whether the flag is set.
389 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
392 if self._msg is None:
393 raise NotmuchError(STATUS.NOT_INITIALIZED)
394 return Message._get_flag(self._msg, flag)
396 def set_flag(self, flag, value):
397 """Sets/Unsets a specific flag for this message
399 :param flag: One of the :attr:`Message.FLAG` values (currently only
401 :param value: A bool indicating whether to set or unset the flag.
404 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
407 if self._msg is None:
408 raise NotmuchError(STATUS.NOT_INITIALIZED)
409 nmlib.notmuch_message_set_flag(self._msg, flag, value)
412 """Returns the message tags
414 :returns: A :class:`Tags` iterator.
415 :exception: :exc:`NotmuchError`
417 * STATUS.NOT_INITIALIZED if the message
419 * STATUS.NULL_POINTER, on error
421 if self._msg is None:
422 raise NotmuchError(STATUS.NOT_INITIALIZED)
424 tags_p = Message._get_tags(self._msg)
426 raise NotmuchError(STATUS.NULL_POINTER)
427 return Tags(tags_p, self)
429 def add_tag(self, tag):
430 """Adds a tag to the given message
432 Adds a tag to the current message. The maximal tag length is defined in
433 the notmuch library and is currently 200 bytes.
435 :param tag: String with a 'tag' to be added.
436 :returns: STATUS.SUCCESS if the tag was successfully added.
437 Raises an exception otherwise.
438 :exception: :exc:`NotmuchError`. They have the following meaning:
441 The 'tag' argument is NULL
443 The length of 'tag' is too long
444 (exceeds Message.NOTMUCH_TAG_MAX)
445 STATUS.READ_ONLY_DATABASE
446 Database was opened in read-only mode so message cannot be
448 STATUS.NOT_INITIALIZED
449 The message has not been initialized.
451 if self._msg is None:
452 raise NotmuchError(STATUS.NOT_INITIALIZED)
454 status = nmlib.notmuch_message_add_tag (self._msg, tag)
456 if STATUS.SUCCESS == status:
460 raise NotmuchError(status)
462 def remove_tag(self, tag):
463 """Removes a tag from the given message
465 If the message has no such tag, this is a non-operation and
466 will report success anyway.
468 :param tag: String with a 'tag' to be removed.
469 :returns: STATUS.SUCCESS if the tag was successfully removed or if
470 the message had no such tag.
471 Raises an exception otherwise.
472 :exception: :exc:`NotmuchError`. They have the following meaning:
475 The 'tag' argument is NULL
477 The length of 'tag' is too long
478 (exceeds NOTMUCH_TAG_MAX)
479 STATUS.READ_ONLY_DATABASE
480 Database was opened in read-only mode so message cannot
482 STATUS.NOT_INITIALIZED
483 The message has not been initialized.
485 if self._msg is None:
486 raise NotmuchError(STATUS.NOT_INITIALIZED)
488 status = nmlib.notmuch_message_remove_tag(self._msg, tag)
490 if STATUS.SUCCESS == status:
494 raise NotmuchError(status)
496 def remove_all_tags(self):
497 """Removes all tags from the given message.
499 See :meth:`freeze` for an example showing how to safely
502 :returns: STATUS.SUCCESS if the tags were successfully removed.
503 Raises an exception otherwise.
504 :exception: :exc:`NotmuchError`. They have the following meaning:
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_all_tags(self._msg)
517 if STATUS.SUCCESS == status:
521 raise NotmuchError(status)
524 """Freezes the current state of 'message' within the database
526 This means that changes to the message state, (via :meth:`add_tag`,
527 :meth:`remove_tag`, and :meth:`remove_all_tags`), will not be
528 committed to the database until the message is :meth:`thaw`ed.
530 Multiple calls to freeze/thaw are valid and these calls will
531 "stack". That is there must be as many calls to thaw as to freeze
532 before a message is actually thawed.
534 The ability to do freeze/thaw allows for safe transactions to
535 change tag values. For example, explicitly setting a message to
536 have a given set of tags might look like this::
539 msg.remove_all_tags()
544 With freeze/thaw used like this, the message in the database is
545 guaranteed to have either the full set of original tag values, or
546 the full set of new tag values, but nothing in between.
548 Imagine the example above without freeze/thaw and the operation
549 somehow getting interrupted. This could result in the message being
550 left with no tags if the interruption happened after
551 :meth:`remove_all_tags` but before :meth:`add_tag`.
553 :returns: STATUS.SUCCESS if the message was successfully frozen.
554 Raises an exception otherwise.
555 :exception: :exc:`NotmuchError`. They have the following meaning:
557 STATUS.READ_ONLY_DATABASE
558 Database was opened in read-only mode so message cannot
560 STATUS.NOT_INITIALIZED
561 The message has not been initialized.
563 if self._msg is None:
564 raise NotmuchError(STATUS.NOT_INITIALIZED)
566 status = nmlib.notmuch_message_freeze(self._msg)
568 if STATUS.SUCCESS == status:
572 raise NotmuchError(status)
575 """Thaws the current 'message'
577 Thaw the current 'message', synchronizing any changes that may have
578 occurred while 'message' was frozen into the notmuch database.
580 See :meth:`freeze` for an example of how to use this
581 function to safely provide tag changes.
583 Multiple calls to freeze/thaw are valid and these calls with
584 "stack". That is there must be as many calls to thaw as to freeze
585 before a message is actually thawed.
587 :returns: STATUS.SUCCESS if the message was successfully frozen.
588 Raises an exception otherwise.
589 :exception: :exc:`NotmuchError`. They have the following meaning:
591 STATUS.UNBALANCED_FREEZE_THAW
592 An attempt was made to thaw an unfrozen message.
593 That is, there have been an unbalanced number of calls
594 to :meth:`freeze` and :meth:`thaw`.
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_thaw(self._msg)
603 if STATUS.SUCCESS == status:
607 raise NotmuchError(status)
611 """(Not implemented)"""
612 return self.get_flag(self.FLAG.MATCH)
615 """A message() is represented by a 1-line summary"""
617 msg['from'] = self.get_header('from')
618 msg['tags'] = str(self.get_tags())
619 msg['date'] = date.fromtimestamp(self.get_date())
620 replies = self.get_replies()
621 msg['replies'] = len(replies) if replies is not None else -1
622 return "%(from)s (%(date)s) (%(tags)s) (%(replies)d) replies" % (msg)
625 def get_message_parts(self):
626 """Output like notmuch show"""
627 fp = open(self.get_filename())
628 email_msg = email.message_from_file(fp)
631 # A subfunction to recursively unpack the message parts into a
633 def msg_unpacker_gen(msg):
634 if not msg.is_multipart():
637 for part in msg.get_payload():
638 for subpart in msg_unpacker_gen(part):
641 return list(msg_unpacker_gen(email_msg))
643 def format_message_internal(self):
644 """Create an internal representation of the message parts,
645 which can easily be output to json, text, or another output
646 format. The argument match tells whether this matched a
649 output["id"] = self.get_message_id()
650 output["match"] = self.is_match()
651 output["filename"] = self.get_filename()
652 output["tags"] = list(self.get_tags())
655 for h in ["subject", "from", "to", "cc", "bcc", "date"]:
656 headers[h] = self.get_header(h)
657 output["headers"] = headers
660 parts = self.get_message_parts()
661 for i in xrange(len(parts)):
664 part_dict["id"] = i + 1
665 # We'll be using this is a lot, so let's just get it once.
666 cont_type = msg.get_content_type()
667 part_dict["content_type"] = cont_type
669 # Now we emulate the current behaviour, where it ignores
670 # the html if there's a text representation.
672 # This is being worked on, but it will be easier to fix
673 # here in the future than to end up with another
674 # incompatible solution.
675 disposition = msg["Content-Disposition"]
677 if disposition.lower().startswith("attachment"):
678 part_dict["filename"] = msg.get_filename()
680 if cont_type.lower() == "text/plain":
681 part_dict["content"] = msg.get_payload()
682 elif (cont_type.lower() == "text/html" and
684 part_dict["content"] = msg.get_payload()
685 body.append(part_dict)
686 output["body"] = body
690 def format_message_as_json(self, indent=0):
691 """Outputs the message as json. This is essentially the same
692 as python's dict format, but we run it through, just so we
693 don't have to worry about the details."""
694 return json.dumps(self.format_message_internal())
696 def format_message_as_text(self, indent=0):
697 """Outputs it in the old-fashioned notmuch text form. Will be
698 easy to change to a new format when the format changes."""
700 format = self.format_message_internal()
701 output = "\n\fmessage{ id:%s depth:%d filename:%s" % (format["id"],
704 output += "\n\fheader{"
706 #Todo: this date is supposed to be cleaned up, as in the index.
707 output += "\n%s (%s) (" % (format["headers"]["from"],
708 format["headers"]["date"])
709 output += ", ".join(format["tags"])
713 output += "\nSubject: %s" % format["headers"]["subject"]
714 output += "\nFrom: %s" % format["headers"]["from"]
715 output += "\nTo: %s" % format["headers"]["to"]
716 if format["headers"]["cc"]:
717 output += "\nCc: %s" % format["headers"]["cc"]
718 if format["headers"]["bcc"]:
719 output += "\nBcc: %s" % format["headers"]["bcc"]
720 output += "\nDate: %s" % format["headers"]["date"]
721 output += "\nheader}\f"
723 output += "\n\fbody{"
725 parts = format["body"]
726 parts.sort(key=lambda(p): p["id"])
728 if not p.has_key("filename"):
729 output += "\n\fpart{ "
730 output += "ID: %d, Content-type:%s\n" % (p["id"],
732 if p.has_key("content"):
733 output += "\n%s\n" % p["content"]
735 output += "Non-text part: %s\n" % p["content_type"]
736 output += "\n\fpart}"
738 output += "\n\fattachment{ "
739 output += "ID: %d, Content-type:%s\n" % (p["id"],
741 output += "Attachment: %s\n" % p["filename"]
742 output += "\n\fattachment}\n"
744 output += "\n\fbody}\n"
745 output += "\n\fmessage}\n"
751 """Close and free the notmuch Message"""
752 if self._msg is not None:
753 nmlib.notmuch_message_destroy (self._msg)