1 # This file is part of cnotmuch.
3 # cnotmuch is free software: you can redistribute it and/or modify
4 # it under the terms of the GNU General Public License as published by
5 # the Free Software Foundation, either version 3 of the License, or
6 # (at your option) any later version.
8 # cnotmuch is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License
14 # along with cnotmuch. If not, see <http://www.gnu.org/licenses/>.
16 # (C) Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de>
17 # Jesse Rosenthal <jrosenthal@jhu.edu>
19 from ctypes import c_char_p, c_void_p, c_long, c_bool
20 from datetime import date
21 from cnotmuch.globals import nmlib, STATUS, NotmuchError, Enum
22 from cnotmuch.tag import Tags
27 import simplejson as json
30 #------------------------------------------------------------------------------
31 class Messages(object):
32 """Represents a list of notmuch messages
34 This object provides an iterator over a list of notmuch messages
35 (Technically, it provides a wrapper for the underlying
36 *notmuch_messages_t* structure). Do note that the underlying
37 library only provides a one-time iterator (it cannot reset the
38 iterator to the start). Thus iterating over the function will
39 "exhaust" the list of messages, and a subsequent iteration attempt
40 will raise a :exc:`NotmuchError` STATUS.NOT_INITIALIZED. Also
41 note, that any function that uses iteration will also
42 exhaust the messages. So both::
44 for msg in msgs: print msg
48 number_of_msgs = len(msgs)
50 will "exhaust" the Messages. If you need to re-iterate over a list of
51 messages you will need to retrieve a new :class:`Messages` object.
53 Things are not as bad as it seems though, you can store and reuse
54 the single Message objects as often as you want as long as you
55 keep the parent Messages object around. (Recall that due to
56 hierarchical memory allocation, all derived Message objects will
57 be invalid when we delete the parent Messages() object, even if it
58 was already "exhausted".) So this works::
61 msgs = Query(db,'').search_messages() #get a Messages() object
66 # msgs is "exhausted" now and even len(msgs) will raise an exception.
67 # However it will be kept around until all retrieved Message() objects are
68 # also deleted. If you did e.g. an explicit del(msgs) here, the
69 # following lines would fail.
71 # You can reiterate over *msglist* however as often as you want.
72 # It is simply a list with Message objects.
74 print (msglist[0].get_filename())
75 print (msglist[1].get_filename())
76 print (msglist[0].get_message_id())
80 _get = nmlib.notmuch_messages_get
81 _get.restype = c_void_p
83 _collect_tags = nmlib.notmuch_messages_collect_tags
84 _collect_tags.restype = c_void_p
86 def __init__(self, msgs_p, parent=None):
88 :param msgs_p: A pointer to an underlying *notmuch_messages_t*
89 structure. These are not publically exposed, so a user
90 will almost never instantiate a :class:`Messages` object
91 herself. They are usually handed back as a result,
92 e.g. in :meth:`Query.search_messages`. *msgs_p* must be
93 valid, we will raise an :exc:`NotmuchError`
94 (STATUS.NULL_POINTER) if it is `None`.
95 :type msgs_p: :class:`ctypes.c_void_p`
96 :param parent: The parent object
97 (ie :class:`Query`) these tags are derived from. It saves
98 a reference to it, so we can automatically delete the db
99 object once all derived objects are dead.
100 :TODO: Make the iterator work more than once and cache the tags in
101 the Python object.(?)
104 NotmuchError(STATUS.NULL_POINTER)
107 #store parent, so we keep them alive as long as self is alive
108 self._parent = parent
110 def collect_tags(self):
111 """Return the unique :class:`Tags` in the contained messages
113 :returns: :class:`Tags`
114 :exceptions: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if not inited
116 .. note:: :meth:`collect_tags` will iterate over the messages and
117 therefore will not allow further iterations.
119 if self._msgs is None:
120 raise NotmuchError(STATUS.NOT_INITIALIZED)
122 # collect all tags (returns NULL on error)
123 tags_p = Messages._collect_tags (self._msgs)
124 #reset _msgs as we iterated over it and can do so only once
128 raise NotmuchError(STATUS.NULL_POINTER)
129 return Tags(tags_p, self)
132 """ Make Messages an iterator """
136 if self._msgs is None:
137 raise NotmuchError(STATUS.NOT_INITIALIZED)
139 if not nmlib.notmuch_messages_valid(self._msgs):
143 msg = Message(Messages._get (self._msgs), self)
144 nmlib.notmuch_messages_move_to_next(self._msgs)
148 """len(:class:`Messages`) returns the number of contained messages
150 .. note:: As this iterates over the messages, we will not be able to
151 iterate over them again! So this will fail::
154 msgs = Database().create_query('').search_message()
155 if len(msgs) > 0: #this 'exhausts' msgs
156 # next line raises NotmuchError(STATUS.NOT_INITIALIZED)!!!
157 for msg in msgs: print msg
159 Most of the time, using the
160 :meth:`Query.count_messages` is therefore more
161 appropriate (and much faster). While not guaranteeing
162 that it will return the exact same number than len(),
163 in my tests it effectively always did so.
165 if self._msgs is None:
166 raise NotmuchError(STATUS.NOT_INITIALIZED)
169 while nmlib.notmuch_messages_valid(self._msgs):
170 nmlib.notmuch_messages_move_to_next(self._msgs)
176 """Close and free the notmuch Messages"""
177 if self._msgs is not None:
178 nmlib.notmuch_messages_destroy (self._msgs)
180 def print_messages(self, format, indent=0, entire_thread=False):
181 """Outputs messages as needed for 'notmuch show' to sys.stdout
183 :param format: A string of either 'text' or 'json'.
184 :param indent: A number indicating the reply depth of these messages.
185 :param entire_thread: A bool, indicating whether we want to output
186 whole threads or only the matching messages.
188 if format.lower() == "text":
192 elif format.lower() == "json":
201 sys.stdout.write(set_start)
203 # iterate through all toplevel messages in this thread
208 sys.stdout.write(set_sep)
211 sys.stdout.write(set_start)
212 match = msg.is_match()
215 if (match or entire_thread):
216 if format.lower() == "text":
217 sys.stdout.write(msg.format_message_as_text(indent))
218 elif format.lower() == "json":
219 sys.stdout.write(msg.format_message_as_json(indent))
222 next_indent = indent + 1
224 #sys.stdout.write(set_end)
225 replies = msg.get_replies()
226 # if isinstance(replies, types.NoneType):
228 if not replies is None:
229 sys.stdout.write(set_sep)
230 replies.print_messages(format, next_indent, entire_thread)
232 sys.stdout.write(set_end)
233 sys.stdout.write(set_end)
235 #------------------------------------------------------------------------------
236 class Message(object):
237 """Represents a single Email message
239 Technically, this wraps the underlying *notmuch_message_t* structure.
242 """notmuch_message_get_filename (notmuch_message_t *message)"""
243 _get_filename = nmlib.notmuch_message_get_filename
244 _get_filename.restype = c_char_p
246 """notmuch_message_get_flag"""
247 _get_flag = nmlib.notmuch_message_get_flag
248 _get_flag.restype = c_bool
250 """notmuch_message_get_message_id (notmuch_message_t *message)"""
251 _get_message_id = nmlib.notmuch_message_get_message_id
252 _get_message_id.restype = c_char_p
254 """notmuch_message_get_thread_id"""
255 _get_thread_id = nmlib.notmuch_message_get_thread_id
256 _get_thread_id.restype = c_char_p
258 """notmuch_message_get_replies"""
259 _get_replies = nmlib.notmuch_message_get_replies
260 _get_replies.restype = c_void_p
262 """notmuch_message_get_tags (notmuch_message_t *message)"""
263 _get_tags = nmlib.notmuch_message_get_tags
264 _get_tags.restype = c_void_p
266 _get_date = nmlib.notmuch_message_get_date
267 _get_date.restype = c_long
269 _get_header = nmlib.notmuch_message_get_header
270 _get_header.restype = c_char_p
272 #Constants: Flags that can be set/get with set_flag
273 FLAG = Enum(['MATCH'])
275 def __init__(self, msg_p, parent=None):
277 :param msg_p: A pointer to an internal notmuch_message_t
278 Structure. If it is `None`, we will raise an :exc:`NotmuchError`
280 :param parent: A 'parent' object is passed which this message is
281 derived from. We save a reference to it, so we can
282 automatically delete the parent object once all derived
286 NotmuchError(STATUS.NULL_POINTER)
288 #keep reference to parent, so we keep it alive
289 self._parent = parent
292 def get_message_id(self):
293 """Returns the message ID
295 :returns: String with a message ID
296 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
299 if self._msg is None:
300 raise NotmuchError(STATUS.NOT_INITIALIZED)
301 return Message._get_message_id(self._msg)
303 def get_thread_id(self):
304 """Returns the thread ID
306 The returned string belongs to 'message' will only be valid for as
307 long as the message is valid.
309 This function will not return None since Notmuch ensures that every
310 message belongs to a single thread.
312 :returns: String with a thread ID
313 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
316 if self._msg is None:
317 raise NotmuchError(STATUS.NOT_INITIALIZED)
319 return Message._get_thread_id (self._msg);
321 def get_replies(self):
322 """Gets all direct replies to this message as :class:`Messages` iterator
324 .. note:: This call only makes sense if 'message' was
325 ultimately obtained from a :class:`Thread` object, (such as
326 by coming directly from the result of calling
327 :meth:`Thread.get_toplevel_messages` or by any number of
328 subsequent calls to :meth:`get_replies`). If this message was
329 obtained through some non-thread means, (such as by a call
330 to :meth:`Query.search_messages`), then this function will
333 :returns: :class:`Messages` or `None` if there are no replies to
335 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
338 if self._msg is None:
339 raise NotmuchError(STATUS.NOT_INITIALIZED)
341 msgs_p = Message._get_replies(self._msg);
346 return Messages(msgs_p,self)
349 """Returns time_t of the message date
351 For the original textual representation of the Date header from the
352 message call notmuch_message_get_header() with a header value of
355 :returns: A time_t timestamp.
357 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
360 if self._msg is None:
361 raise NotmuchError(STATUS.NOT_INITIALIZED)
362 return Message._get_date(self._msg)
364 def get_header(self, header):
365 """Returns a message header
367 This returns any message header that is stored in the notmuch database.
368 This is only a selected subset of headers, which is currently:
370 TODO: add stored headers
372 :param header: The name of the header to be retrieved.
373 It is not case-sensitive (TODO: confirm).
375 :returns: The header value as string
376 :exception: :exc:`NotmuchError`
378 * STATUS.NOT_INITIALIZED if the message
380 * STATUS.NULL_POINTER, if no header was found
382 if self._msg is None:
383 raise NotmuchError(STATUS.NOT_INITIALIZED)
385 #Returns NULL if any error occurs.
386 header = Message._get_header (self._msg, header)
388 raise NotmuchError(STATUS.NULL_POINTER)
391 def get_filename(self):
392 """Returns the file path of the message file
394 :returns: Absolute file path & name of the message file
395 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
398 if self._msg is None:
399 raise NotmuchError(STATUS.NOT_INITIALIZED)
400 return Message._get_filename(self._msg)
402 def get_flag(self, flag):
403 """Checks whether a specific flag is set for this message
405 The method :meth:`Query.search_threads` sets
406 *Message.FLAG.MATCH* for those messages that match the
407 query. This method allows us to get the value of this flag.
409 :param flag: One of the :attr:`Message.FLAG` values (currently only
411 :returns: A bool, indicating whether the flag is set.
412 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
415 if self._msg is None:
416 raise NotmuchError(STATUS.NOT_INITIALIZED)
417 return Message._get_flag(self._msg, flag)
419 def set_flag(self, flag, value):
420 """Sets/Unsets a specific flag for this message
422 :param flag: One of the :attr:`Message.FLAG` values (currently only
424 :param value: A bool indicating whether to set or unset the flag.
427 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
430 if self._msg is None:
431 raise NotmuchError(STATUS.NOT_INITIALIZED)
432 nmlib.notmuch_message_set_flag(self._msg, flag, value)
435 """Returns the message tags
437 :returns: A :class:`Tags` iterator.
438 :exception: :exc:`NotmuchError`
440 * STATUS.NOT_INITIALIZED if the message
442 * STATUS.NULL_POINTER, on error
444 if self._msg is None:
445 raise NotmuchError(STATUS.NOT_INITIALIZED)
447 tags_p = Message._get_tags(self._msg)
449 raise NotmuchError(STATUS.NULL_POINTER)
450 return Tags(tags_p, self)
452 def add_tag(self, tag):
453 """Adds a tag to the given message
455 Adds a tag to the current message. The maximal tag length is defined in
456 the notmuch library and is currently 200 bytes.
458 :param tag: String with a 'tag' to be added.
459 :returns: STATUS.SUCCESS if the tag was successfully added.
460 Raises an exception otherwise.
461 :exception: :exc:`NotmuchError`. They have the following meaning:
464 The 'tag' argument is NULL
466 The length of 'tag' is too long
467 (exceeds Message.NOTMUCH_TAG_MAX)
468 STATUS.READ_ONLY_DATABASE
469 Database was opened in read-only mode so message cannot be
471 STATUS.NOT_INITIALIZED
472 The message has not been initialized.
474 if self._msg is None:
475 raise NotmuchError(STATUS.NOT_INITIALIZED)
477 status = nmlib.notmuch_message_add_tag (self._msg, tag)
479 if STATUS.SUCCESS == status:
483 raise NotmuchError(status)
485 def remove_tag(self, tag):
486 """Removes a tag from the given message
488 If the message has no such tag, this is a non-operation and
489 will report success anyway.
491 :param tag: String with a 'tag' to be removed.
492 :returns: STATUS.SUCCESS if the tag was successfully removed or if
493 the message had no such tag.
494 Raises an exception otherwise.
495 :exception: :exc:`NotmuchError`. They have the following meaning:
498 The 'tag' argument is NULL
500 The length of 'tag' is too long
501 (exceeds NOTMUCH_TAG_MAX)
502 STATUS.READ_ONLY_DATABASE
503 Database was opened in read-only mode so message cannot
505 STATUS.NOT_INITIALIZED
506 The message has not been initialized.
508 if self._msg is None:
509 raise NotmuchError(STATUS.NOT_INITIALIZED)
511 status = nmlib.notmuch_message_remove_tag(self._msg, tag)
513 if STATUS.SUCCESS == status:
517 raise NotmuchError(status)
519 def remove_all_tags(self):
520 """Removes all tags from the given message.
522 See :meth:`freeze` for an example showing how to safely
525 :returns: STATUS.SUCCESS if the tags were successfully removed.
526 Raises an exception otherwise.
527 :exception: :exc:`NotmuchError`. They have the following meaning:
529 STATUS.READ_ONLY_DATABASE
530 Database was opened in read-only mode so message cannot
532 STATUS.NOT_INITIALIZED
533 The message has not been initialized.
535 if self._msg is None:
536 raise NotmuchError(STATUS.NOT_INITIALIZED)
538 status = nmlib.notmuch_message_remove_all_tags(self._msg)
540 if STATUS.SUCCESS == status:
544 raise NotmuchError(status)
547 """Freezes the current state of 'message' within the database
549 This means that changes to the message state, (via :meth:`add_tag`,
550 :meth:`remove_tag`, and :meth:`remove_all_tags`), will not be
551 committed to the database until the message is :meth:`thaw`ed.
553 Multiple calls to freeze/thaw are valid and these calls will
554 "stack". That is there must be as many calls to thaw as to freeze
555 before a message is actually thawed.
557 The ability to do freeze/thaw allows for safe transactions to
558 change tag values. For example, explicitly setting a message to
559 have a given set of tags might look like this::
562 msg.remove_all_tags()
567 With freeze/thaw used like this, the message in the database is
568 guaranteed to have either the full set of original tag values, or
569 the full set of new tag values, but nothing in between.
571 Imagine the example above without freeze/thaw and the operation
572 somehow getting interrupted. This could result in the message being
573 left with no tags if the interruption happened after
574 :meth:`remove_all_tags` but before :meth:`add_tag`.
576 :returns: STATUS.SUCCESS if the message was successfully frozen.
577 Raises an exception otherwise.
578 :exception: :exc:`NotmuchError`. They have the following meaning:
580 STATUS.READ_ONLY_DATABASE
581 Database was opened in read-only mode so message cannot
583 STATUS.NOT_INITIALIZED
584 The message has not been initialized.
586 if self._msg is None:
587 raise NotmuchError(STATUS.NOT_INITIALIZED)
589 status = nmlib.notmuch_message_freeze(self._msg)
591 if STATUS.SUCCESS == status:
595 raise NotmuchError(status)
598 """Thaws the current 'message'
600 Thaw the current 'message', synchronizing any changes that may have
601 occurred while 'message' was frozen into the notmuch database.
603 See :meth:`freeze` for an example of how to use this
604 function to safely provide tag changes.
606 Multiple calls to freeze/thaw are valid and these calls with
607 "stack". That is there must be as many calls to thaw as to freeze
608 before a message is actually thawed.
610 :returns: STATUS.SUCCESS if the message was successfully frozen.
611 Raises an exception otherwise.
612 :exception: :exc:`NotmuchError`. They have the following meaning:
614 STATUS.UNBALANCED_FREEZE_THAW
615 An attempt was made to thaw an unfrozen message.
616 That is, there have been an unbalanced number of calls
617 to :meth:`freeze` and :meth:`thaw`.
618 STATUS.NOT_INITIALIZED
619 The message has not been initialized.
621 if self._msg is None:
622 raise NotmuchError(STATUS.NOT_INITIALIZED)
624 status = nmlib.notmuch_message_thaw(self._msg)
626 if STATUS.SUCCESS == status:
630 raise NotmuchError(status)
634 """(Not implemented)"""
635 return self.get_flag(Message.FLAG.MATCH)
638 """A message() is represented by a 1-line summary"""
640 msg['from'] = self.get_header('from')
641 msg['tags'] = str(self.get_tags())
642 msg['date'] = date.fromtimestamp(self.get_date())
643 replies = self.get_replies()
644 msg['replies'] = len(replies) if replies is not None else -1
645 return "%(from)s (%(date)s) (%(tags)s) (%(replies)d) replies" % (msg)
648 def get_message_parts(self):
649 """Output like notmuch show"""
650 fp = open(self.get_filename())
651 email_msg = email.message_from_file(fp)
654 # A subfunction to recursively unpack the message parts into a
656 # def msg_unpacker_gen(msg):
657 # if not msg.is_multipart():
660 # for part in msg.get_payload():
661 # for subpart in msg_unpacker_gen(part):
664 # return list(msg_unpacker_gen(email_msg))
666 for msg in email_msg.walk():
667 if not msg.is_multipart():
671 def get_part(self, num):
672 parts = self.get_message_parts()
673 if (num <= 0 or num > len(parts)):
676 out_part = parts[(num - 1)]
677 return out_part.get_payload(decode=True)
679 def format_message_internal(self):
680 """Create an internal representation of the message parts,
681 which can easily be output to json, text, or another output
682 format. The argument match tells whether this matched a
685 output["id"] = self.get_message_id()
686 output["match"] = self.is_match()
687 output["filename"] = self.get_filename()
688 output["tags"] = list(self.get_tags())
691 for h in ["Subject", "From", "To", "Cc", "Bcc", "Date"]:
692 headers[h] = self.get_header(h)
693 output["headers"] = headers
696 parts = self.get_message_parts()
697 for i in xrange(len(parts)):
700 part_dict["id"] = i + 1
701 # We'll be using this is a lot, so let's just get it once.
702 cont_type = msg.get_content_type()
703 part_dict["content-type"] = cont_type
705 # Now we emulate the current behaviour, where it ignores
706 # the html if there's a text representation.
708 # This is being worked on, but it will be easier to fix
709 # here in the future than to end up with another
710 # incompatible solution.
711 disposition = msg["Content-Disposition"]
712 if disposition and disposition.lower().startswith("attachment"):
713 part_dict["filename"] = msg.get_filename()
715 if cont_type.lower() == "text/plain":
716 part_dict["content"] = msg.get_payload()
717 elif (cont_type.lower() == "text/html" and
719 part_dict["content"] = msg.get_payload()
720 body.append(part_dict)
722 output["body"] = body
726 def format_message_as_json(self, indent=0):
727 """Outputs the message as json. This is essentially the same
728 as python's dict format, but we run it through, just so we
729 don't have to worry about the details."""
730 return json.dumps(self.format_message_internal())
732 def format_message_as_text(self, indent=0):
733 """Outputs it in the old-fashioned notmuch text form. Will be
734 easy to change to a new format when the format changes."""
736 format = self.format_message_internal()
737 output = "\fmessage{ id:%s depth:%d match:%d filename:%s" \
738 % (format['id'], indent, format['match'], format['filename'])
739 output += "\n\fheader{"
741 #Todo: this date is supposed to be prettified, as in the index.
742 output += "\n%s (%s) (" % (format["headers"]["from"],
743 format["headers"]["date"])
744 output += ", ".join(format["tags"])
747 output += "\nSubject: %s" % format["headers"]["subject"]
748 output += "\nFrom: %s" % format["headers"]["from"]
749 output += "\nTo: %s" % format["headers"]["to"]
750 if format["headers"]["cc"]:
751 output += "\nCc: %s" % format["headers"]["cc"]
752 if format["headers"]["bcc"]:
753 output += "\nBcc: %s" % format["headers"]["bcc"]
754 output += "\nDate: %s" % format["headers"]["date"]
755 output += "\n\fheader}"
757 output += "\n\fbody{"
759 parts = format["body"]
760 parts.sort(key=lambda(p): p["id"])
762 if not p.has_key("filename"):
763 output += "\n\fpart{ "
764 output += "ID: %d, Content-type: %s\n" % (p["id"],
766 if p.has_key("content"):
767 output += "\n%s\n" % p["content"]
769 output += "Non-text part: %s\n" % p["content_type"]
770 output += "\n\fpart}"
772 output += "\n\fattachment{ "
773 output += "ID: %d, Content-type:%s\n" % (p["id"],
775 output += "Attachment: %s\n" % p["filename"]
776 output += "\n\fattachment}\n"
778 output += "\n\fbody}\n"
779 output += "\n\fmessage}"
784 """Close and free the notmuch Message"""
785 if self._msg is not None:
786 nmlib.notmuch_message_destroy (self._msg)