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)
178 """Close and free the notmuch Messages"""
179 if self._msgs is not None:
180 nmlib.notmuch_messages_destroy (self._msgs)
182 def show_messages(self, format, indent=0, entire_thread=True):
183 if format.lower() == "text":
187 elif format.lower() == "json":
196 sys.stdout.write(set_start)
202 sys.stdout.write(set_sep)
205 sys.stdout.write(set_start)
206 match = msg.is_match()
209 if (match or entire_thread):
210 if format.lower() == "text":
211 sys.stdout.write(msg.format_message_as_text(indent))
212 elif format.lower() == "json":
213 sys.stdout.write(msg.format_message_as_json(indent))
216 next_indent = indent + 1
219 replies = msg.get_replies()
220 # if isinstance(replies, types.NoneType):
222 if not replies is None:
223 sys.stdout.write(set_sep)
224 replies.show_messages(format, next_indent, entire_thread)
227 sys.stdout.write(set_end)
228 sys.stdout.write(set_end)
230 #------------------------------------------------------------------------------
231 class Message(object):
232 """Represents a single Email message
234 Technically, this wraps the underlying *notmuch_message_t* structure.
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 """notmuch_message_get_flag"""
242 _get_flag = nmlib.notmuch_message_get_flag
243 _get_flag.restype = c_bool
245 """notmuch_message_get_message_id (notmuch_message_t *message)"""
246 _get_message_id = nmlib.notmuch_message_get_message_id
247 _get_message_id.restype = c_char_p
249 """notmuch_message_get_thread_id"""
250 _get_thread_id = nmlib.notmuch_message_get_thread_id
251 _get_thread_id.restype = c_char_p
253 """notmuch_message_get_replies"""
254 _get_replies = nmlib.notmuch_message_get_replies
255 _get_replies.restype = c_void_p
257 """notmuch_message_get_tags (notmuch_message_t *message)"""
258 _get_tags = nmlib.notmuch_message_get_tags
259 _get_tags.restype = c_void_p
261 _get_date = nmlib.notmuch_message_get_date
262 _get_date.restype = c_long
264 _get_header = nmlib.notmuch_message_get_header
265 _get_header.restype = c_char_p
267 #Constants: Flags that can be set/get with set_flag
268 FLAG = Enum(['MATCH'])
270 def __init__(self, msg_p, parent=None):
272 :param msg_p: A pointer to an internal notmuch_message_t
273 Structure. If it is `None`, we will raise an :exc:`NotmuchError`
275 :param parent: A 'parent' object is passed which this message is
276 derived from. We save a reference to it, so we can
277 automatically delete the parent object once all derived
281 NotmuchError(STATUS.NULL_POINTER)
283 #keep reference to parent, so we keep it alive
284 self._parent = parent
287 def get_message_id(self):
288 """Returns the message ID
290 :returns: String with a message ID
291 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
294 if self._msg is None:
295 raise NotmuchError(STATUS.NOT_INITIALIZED)
296 return Message._get_message_id(self._msg)
298 def get_thread_id(self):
299 """Returns the thread ID
301 The returned string belongs to 'message' will only be valid for as
302 long as the message is valid.
304 This function will not return None since Notmuch ensures that every
305 message belongs to a single thread.
307 :returns: String with a thread ID
308 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
311 if self._msg is None:
312 raise NotmuchError(STATUS.NOT_INITIALIZED)
314 return Message._get_thread_id (self._msg);
316 def get_replies(self):
317 """Gets all direct replies to this message as :class:`Messages` iterator
319 .. note:: This call only makes sense if 'message' was
320 ultimately obtained from a :class:`Thread` object, (such as
321 by coming directly from the result of calling
322 :meth:`Thread.get_toplevel_messages` or by any number of
323 subsequent calls to :meth:`get_replies`). If this message was
324 obtained through some non-thread means, (such as by a call
325 to :meth:`Query.search_messages`), then this function will
328 :returns: :class:`Messages` or `None` if there are no replies to
330 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
333 if self._msg is None:
334 raise NotmuchError(STATUS.NOT_INITIALIZED)
336 msgs_p = Message._get_replies(self._msg);
341 return Messages(msgs_p,self)
344 """Returns time_t of the message date
346 For the original textual representation of the Date header from the
347 message call notmuch_message_get_header() with a header value of
350 :returns: A time_t timestamp.
352 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
355 if self._msg is None:
356 raise NotmuchError(STATUS.NOT_INITIALIZED)
357 return Message._get_date(self._msg)
359 def get_header(self, header):
360 """Returns a message header
362 This returns any message header that is stored in the notmuch database.
363 This is only a selected subset of headers, which is currently:
365 TODO: add stored headers
367 :param header: The name of the header to be retrieved.
368 It is not case-sensitive (TODO: confirm).
370 :returns: The header value as string
371 :exception: :exc:`NotmuchError`
373 * STATUS.NOT_INITIALIZED if the message
375 * STATUS.NULL_POINTER, if no header was found
377 if self._msg is None:
378 raise NotmuchError(STATUS.NOT_INITIALIZED)
380 #Returns NULL if any error occurs.
381 header = Message._get_header (self._msg, header)
383 raise NotmuchError(STATUS.NULL_POINTER)
386 def get_filename(self):
387 """Returns the file path of the message file
389 :returns: Absolute file path & name of the message file
390 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
393 if self._msg is None:
394 raise NotmuchError(STATUS.NOT_INITIALIZED)
395 return Message._get_filename(self._msg)
397 def get_flag(self, flag):
398 """Checks whether a specific flag is set for this message
400 The method :meth:`Query.search_threads` sets
401 *Message.FLAG.MATCH* for those messages that match the
402 query. This method allows us to get the value of this flag.
404 :param flag: One of the :attr:`Message.FLAG` values (currently only
406 :returns: A bool, indicating whether the flag is set.
407 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
410 if self._msg is None:
411 raise NotmuchError(STATUS.NOT_INITIALIZED)
412 return Message._get_flag(self._msg, flag)
414 def set_flag(self, flag, value):
415 """Sets/Unsets a specific flag for this message
417 :param flag: One of the :attr:`Message.FLAG` values (currently only
419 :param value: A bool indicating whether to set or unset the flag.
422 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
425 if self._msg is None:
426 raise NotmuchError(STATUS.NOT_INITIALIZED)
427 nmlib.notmuch_message_set_flag(self._msg, flag, value)
430 """Returns the message tags
432 :returns: A :class:`Tags` iterator.
433 :exception: :exc:`NotmuchError`
435 * STATUS.NOT_INITIALIZED if the message
437 * STATUS.NULL_POINTER, on error
439 if self._msg is None:
440 raise NotmuchError(STATUS.NOT_INITIALIZED)
442 tags_p = Message._get_tags(self._msg)
444 raise NotmuchError(STATUS.NULL_POINTER)
445 return Tags(tags_p, self)
447 def add_tag(self, tag):
448 """Adds a tag to the given message
450 Adds a tag to the current message. The maximal tag length is defined in
451 the notmuch library and is currently 200 bytes.
453 :param tag: String with a 'tag' to be added.
454 :returns: STATUS.SUCCESS if the tag was successfully added.
455 Raises an exception otherwise.
456 :exception: :exc:`NotmuchError`. They have the following meaning:
459 The 'tag' argument is NULL
461 The length of 'tag' is too long
462 (exceeds Message.NOTMUCH_TAG_MAX)
463 STATUS.READ_ONLY_DATABASE
464 Database was opened in read-only mode so message cannot be
466 STATUS.NOT_INITIALIZED
467 The message has not been initialized.
469 if self._msg is None:
470 raise NotmuchError(STATUS.NOT_INITIALIZED)
472 status = nmlib.notmuch_message_add_tag (self._msg, tag)
474 if STATUS.SUCCESS == status:
478 raise NotmuchError(status)
480 def remove_tag(self, tag):
481 """Removes a tag from the given message
483 If the message has no such tag, this is a non-operation and
484 will report success anyway.
486 :param tag: String with a 'tag' to be removed.
487 :returns: STATUS.SUCCESS if the tag was successfully removed or if
488 the message had no such tag.
489 Raises an exception otherwise.
490 :exception: :exc:`NotmuchError`. They have the following meaning:
493 The 'tag' argument is NULL
495 The length of 'tag' is too long
496 (exceeds NOTMUCH_TAG_MAX)
497 STATUS.READ_ONLY_DATABASE
498 Database was opened in read-only mode so message cannot
500 STATUS.NOT_INITIALIZED
501 The message has not been initialized.
503 if self._msg is None:
504 raise NotmuchError(STATUS.NOT_INITIALIZED)
506 status = nmlib.notmuch_message_remove_tag(self._msg, tag)
508 if STATUS.SUCCESS == status:
512 raise NotmuchError(status)
514 def remove_all_tags(self):
515 """Removes all tags from the given message.
517 See :meth:`freeze` for an example showing how to safely
520 :returns: STATUS.SUCCESS if the tags were successfully removed.
521 Raises an exception otherwise.
522 :exception: :exc:`NotmuchError`. They have the following meaning:
524 STATUS.READ_ONLY_DATABASE
525 Database was opened in read-only mode so message cannot
527 STATUS.NOT_INITIALIZED
528 The message has not been initialized.
530 if self._msg is None:
531 raise NotmuchError(STATUS.NOT_INITIALIZED)
533 status = nmlib.notmuch_message_remove_all_tags(self._msg)
535 if STATUS.SUCCESS == status:
539 raise NotmuchError(status)
542 """Freezes the current state of 'message' within the database
544 This means that changes to the message state, (via :meth:`add_tag`,
545 :meth:`remove_tag`, and :meth:`remove_all_tags`), will not be
546 committed to the database until the message is :meth:`thaw`ed.
548 Multiple calls to freeze/thaw are valid and these calls will
549 "stack". That is there must be as many calls to thaw as to freeze
550 before a message is actually thawed.
552 The ability to do freeze/thaw allows for safe transactions to
553 change tag values. For example, explicitly setting a message to
554 have a given set of tags might look like this::
557 msg.remove_all_tags()
562 With freeze/thaw used like this, the message in the database is
563 guaranteed to have either the full set of original tag values, or
564 the full set of new tag values, but nothing in between.
566 Imagine the example above without freeze/thaw and the operation
567 somehow getting interrupted. This could result in the message being
568 left with no tags if the interruption happened after
569 :meth:`remove_all_tags` but before :meth:`add_tag`.
571 :returns: STATUS.SUCCESS if the message was successfully frozen.
572 Raises an exception otherwise.
573 :exception: :exc:`NotmuchError`. They have the following meaning:
575 STATUS.READ_ONLY_DATABASE
576 Database was opened in read-only mode so message cannot
578 STATUS.NOT_INITIALIZED
579 The message has not been initialized.
581 if self._msg is None:
582 raise NotmuchError(STATUS.NOT_INITIALIZED)
584 status = nmlib.notmuch_message_freeze(self._msg)
586 if STATUS.SUCCESS == status:
590 raise NotmuchError(status)
593 """Thaws the current 'message'
595 Thaw the current 'message', synchronizing any changes that may have
596 occurred while 'message' was frozen into the notmuch database.
598 See :meth:`freeze` for an example of how to use this
599 function to safely provide tag changes.
601 Multiple calls to freeze/thaw are valid and these calls with
602 "stack". That is there must be as many calls to thaw as to freeze
603 before a message is actually thawed.
605 :returns: STATUS.SUCCESS if the message was successfully frozen.
606 Raises an exception otherwise.
607 :exception: :exc:`NotmuchError`. They have the following meaning:
609 STATUS.UNBALANCED_FREEZE_THAW
610 An attempt was made to thaw an unfrozen message.
611 That is, there have been an unbalanced number of calls
612 to :meth:`freeze` and :meth:`thaw`.
613 STATUS.NOT_INITIALIZED
614 The message has not been initialized.
616 if self._msg is None:
617 raise NotmuchError(STATUS.NOT_INITIALIZED)
619 status = nmlib.notmuch_message_thaw(self._msg)
621 if STATUS.SUCCESS == status:
625 raise NotmuchError(status)
629 """(Not implemented)"""
630 return self.get_flag(Message.FLAG.MATCH)
633 """A message() is represented by a 1-line summary"""
635 msg['from'] = self.get_header('from')
636 msg['tags'] = str(self.get_tags())
637 msg['date'] = date.fromtimestamp(self.get_date())
638 replies = self.get_replies()
639 msg['replies'] = len(replies) if replies is not None else -1
640 return "%(from)s (%(date)s) (%(tags)s) (%(replies)d) replies" % (msg)
643 def get_message_parts(self):
644 """Output like notmuch show"""
645 fp = open(self.get_filename())
646 email_msg = email.message_from_file(fp)
649 # A subfunction to recursively unpack the message parts into a
651 def msg_unpacker_gen(msg):
652 if not msg.is_multipart():
655 for part in msg.get_payload():
656 for subpart in msg_unpacker_gen(part):
659 return list(msg_unpacker_gen(email_msg))
661 def format_message_internal(self):
662 """Create an internal representation of the message parts,
663 which can easily be output to json, text, or another output
664 format. The argument match tells whether this matched a
667 output["id"] = self.get_message_id()
668 output["match"] = self.is_match()
669 output["filename"] = self.get_filename()
670 output["tags"] = list(self.get_tags())
673 for h in ["subject", "from", "to", "cc", "bcc", "date"]:
674 headers[h] = self.get_header(h)
675 output["headers"] = headers
678 parts = self.get_message_parts()
679 for i in xrange(len(parts)):
682 part_dict["id"] = i + 1
683 # We'll be using this is a lot, so let's just get it once.
684 cont_type = msg.get_content_type()
685 part_dict["content_type"] = cont_type
687 # Now we emulate the current behaviour, where it ignores
688 # the html if there's a text representation.
690 # This is being worked on, but it will be easier to fix
691 # here in the future than to end up with another
692 # incompatible solution.
693 disposition = msg["Content-Disposition"]
695 if disposition.lower().startswith("attachment"):
696 part_dict["filename"] = msg.get_filename()
698 if cont_type.lower() == "text/plain":
699 part_dict["content"] = msg.get_payload()
700 elif (cont_type.lower() == "text/html" and
702 part_dict["content"] = msg.get_payload()
703 body.append(part_dict)
704 output["body"] = body
708 def format_message_as_json(self, indent=0):
709 """Outputs the message as json. This is essentially the same
710 as python's dict format, but we run it through, just so we
711 don't have to worry about the details."""
712 return json.dumps(self.format_message_internal())
714 def format_message_as_text(self, indent=0):
715 """Outputs it in the old-fashioned notmuch text form. Will be
716 easy to change to a new format when the format changes."""
718 format = self.format_message_internal()
719 output = "\n\fmessage{ id:%s depth:%d match:%d filename:%s" \
720 % (format['id'], indent, format['match'], format['filename'])
721 output += "\n\fheader{"
723 #Todo: this date is supposed to be cleaned up, as in the index.
724 output += "\n%s (%s) (" % (format["headers"]["from"],
725 format["headers"]["date"])
726 output += ", ".join(format["tags"])
730 output += "\nSubject: %s" % format["headers"]["subject"]
731 output += "\nFrom: %s" % format["headers"]["from"]
732 output += "\nTo: %s" % format["headers"]["to"]
733 if format["headers"]["cc"]:
734 output += "\nCc: %s" % format["headers"]["cc"]
735 if format["headers"]["bcc"]:
736 output += "\nBcc: %s" % format["headers"]["bcc"]
737 output += "\nDate: %s" % format["headers"]["date"]
738 output += "\nheader}\f"
740 output += "\n\fbody{"
742 parts = format["body"]
743 parts.sort(key=lambda(p): p["id"])
745 if not p.has_key("filename"):
746 output += "\n\fpart{ "
747 output += "ID: %d, Content-type:%s\n" % (p["id"],
749 if p.has_key("content"):
750 output += "\n%s\n" % p["content"]
752 output += "Non-text part: %s\n" % p["content_type"]
753 output += "\n\fpart}"
755 output += "\n\fattachment{ "
756 output += "ID: %d, Content-type:%s\n" % (p["id"],
758 output += "Attachment: %s\n" % p["filename"]
759 output += "\n\fattachment}\n"
761 output += "\n\fbody}\n"
762 output += "\n\fmessage}\n"
768 """Close and free the notmuch Message"""
769 if self._msg is not None:
770 nmlib.notmuch_message_destroy (self._msg)