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
41 library only provides a one-time iterator (it cannot reset the
42 iterator to the start). Thus iterating over the function will
43 "exhaust" the list of messages, and a subsequent iteration attempt
44 will raise a :exc:`NotmuchError` STATUS.NOT_INITIALIZED. Also
45 note, that any function that uses iteration will also
46 exhaust the messages. So both::
48 for msg in msgs: print msg
52 number_of_msgs = len(msgs)
54 will "exhaust" the Messages. If you need to re-iterate over a list of
55 messages you will need to retrieve a new :class:`Messages` object.
57 Things are not as bad as it seems though, you can store and reuse
58 the single Message objects as often as you want as long as you
59 keep the parent Messages object around. (Recall that due to
60 hierarchical memory allocation, all derived Message objects will
61 be invalid when we delete the parent Messages() object, even if it
62 was already "exhausted".) So this works::
65 msgs = Query(db,'').search_messages() #get a Messages() object
70 # msgs is "exhausted" now and even len(msgs) will raise an exception.
71 # However it will be kept around until all retrieved Message() objects are
72 # also deleted. If you did e.g. an explicit del(msgs) here, the
73 # following lines would fail.
75 # You can reiterate over *msglist* however as often as you want.
76 # It is simply a list with Message objects.
78 print (msglist[0].get_filename())
79 print (msglist[1].get_filename())
80 print (msglist[0].get_message_id())
84 _get = nmlib.notmuch_messages_get
85 _get.restype = c_void_p
87 _collect_tags = nmlib.notmuch_messages_collect_tags
88 _collect_tags.restype = c_void_p
90 def __init__(self, msgs_p, parent=None):
92 :param msgs_p: A pointer to an underlying *notmuch_messages_t*
93 structure. These are not publically exposed, so a user
94 will almost never instantiate a :class:`Messages` object
95 herself. They are usually handed back as a result,
96 e.g. in :meth:`Query.search_messages`. *msgs_p* must be
97 valid, we will raise an :exc:`NotmuchError`
98 (STATUS.NULL_POINTER) if it is `None`.
99 :type msgs_p: :class:`ctypes.c_void_p`
100 :param parent: The parent object
101 (ie :class:`Query`) these tags are derived from. It saves
102 a reference to it, so we can automatically delete the db
103 object once all derived objects are dead.
104 :TODO: Make the iterator work more than once and cache the tags in
105 the Python object.(?)
108 NotmuchError(STATUS.NULL_POINTER)
111 #store parent, so we keep them alive as long as self is alive
112 self._parent = parent
114 def collect_tags(self):
115 """Return the unique :class:`Tags` in the contained messages
117 :returns: :class:`Tags`
118 :exceptions: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if not inited
120 .. note:: :meth:`collect_tags` will iterate over the messages and
121 therefore will not allow further iterations.
123 if self._msgs is None:
124 raise NotmuchError(STATUS.NOT_INITIALIZED)
126 # collect all tags (returns NULL on error)
127 tags_p = Messages._collect_tags (self._msgs)
128 #reset _msgs as we iterated over it and can do so only once
132 raise NotmuchError(STATUS.NULL_POINTER)
133 return Tags(tags_p, self)
136 """ Make Messages an iterator """
140 if self._msgs is None:
141 raise NotmuchError(STATUS.NOT_INITIALIZED)
143 if not nmlib.notmuch_messages_valid(self._msgs):
147 msg = Message(Messages._get (self._msgs), self)
148 nmlib.notmuch_messages_move_to_next(self._msgs)
152 """len(:class:`Messages`) returns the number of contained messages
154 .. note:: As this iterates over the messages, we will not be able to
155 iterate over them again! So this will fail::
158 msgs = Database().create_query('').search_message()
159 if len(msgs) > 0: #this 'exhausts' msgs
160 # next line raises NotmuchError(STATUS.NOT_INITIALIZED)!!!
161 for msg in msgs: print msg
163 Most of the time, using the
164 :meth:`Query.count_messages` is therefore more
165 appropriate (and much faster). While not guaranteeing
166 that it will return the exact same number than len(),
167 in my tests it effectively always did so.
169 if self._msgs is None:
170 raise NotmuchError(STATUS.NOT_INITIALIZED)
173 while nmlib.notmuch_messages_valid(self._msgs):
174 nmlib.notmuch_messages_move_to_next(self._msgs)
180 """Close and free the notmuch Messages"""
181 if self._msgs is not None:
182 nmlib.notmuch_messages_destroy (self._msgs)
184 def print_messages(self, format, indent=0, entire_thread=False):
185 """Outputs messages as needed for 'notmuch show' to sys.stdout
187 :param format: A string of either 'text' or 'json'.
188 :param indent: A number indicating the reply depth of these messages.
189 :param entire_thread: A bool, indicating whether we want to output
190 whole threads or only the matching messages.
192 if format.lower() == "text":
196 elif format.lower() == "json":
205 sys.stdout.write(set_start)
207 # iterate through all toplevel messages in this thread
212 sys.stdout.write(set_sep)
215 sys.stdout.write(set_start)
216 match = msg.is_match()
219 if (match or entire_thread):
220 if format.lower() == "text":
221 sys.stdout.write(msg.format_message_as_text(indent))
222 elif format.lower() == "json":
223 sys.stdout.write(msg.format_message_as_json(indent))
226 next_indent = indent + 1
228 # get replies and print them also out (if there are any)
229 replies = msg.get_replies()
230 if not replies is None:
231 sys.stdout.write(set_sep)
232 replies.print_messages(format, next_indent, entire_thread)
234 sys.stdout.write(set_end)
235 sys.stdout.write(set_end)
237 #------------------------------------------------------------------------------
238 class Message(object):
239 """Represents a single Email message
241 Technically, this wraps the underlying *notmuch_message_t* structure.
244 """notmuch_message_get_filename (notmuch_message_t *message)"""
245 _get_filename = nmlib.notmuch_message_get_filename
246 _get_filename.restype = c_char_p
248 """return all filenames for a message"""
249 _get_filenames = nmlib.notmuch_message_get_filenames
250 _get_filenames.restype = c_void_p
252 """notmuch_message_get_flag"""
253 _get_flag = nmlib.notmuch_message_get_flag
254 _get_flag.restype = c_uint
256 """notmuch_message_get_message_id (notmuch_message_t *message)"""
257 _get_message_id = nmlib.notmuch_message_get_message_id
258 _get_message_id.restype = c_char_p
260 """notmuch_message_get_thread_id"""
261 _get_thread_id = nmlib.notmuch_message_get_thread_id
262 _get_thread_id.restype = c_char_p
264 """notmuch_message_get_replies"""
265 _get_replies = nmlib.notmuch_message_get_replies
266 _get_replies.restype = c_void_p
268 """notmuch_message_get_tags (notmuch_message_t *message)"""
269 _get_tags = nmlib.notmuch_message_get_tags
270 _get_tags.restype = c_void_p
272 _get_date = nmlib.notmuch_message_get_date
273 _get_date.restype = c_long
275 _get_header = nmlib.notmuch_message_get_header
276 _get_header.restype = c_char_p
278 #Constants: Flags that can be set/get with set_flag
279 FLAG = Enum(['MATCH'])
281 def __init__(self, msg_p, parent=None):
283 :param msg_p: A pointer to an internal notmuch_message_t
284 Structure. If it is `None`, we will raise an :exc:`NotmuchError`
286 :param parent: A 'parent' object is passed which this message is
287 derived from. We save a reference to it, so we can
288 automatically delete the parent object once all derived
292 NotmuchError(STATUS.NULL_POINTER)
294 #keep reference to parent, so we keep it alive
295 self._parent = parent
298 def get_message_id(self):
299 """Returns the message ID
301 :returns: String with a message ID
302 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
305 if self._msg is None:
306 raise NotmuchError(STATUS.NOT_INITIALIZED)
307 return Message._get_message_id(self._msg)
309 def get_thread_id(self):
310 """Returns the thread ID
312 The returned string belongs to 'message' will only be valid for as
313 long as the message is valid.
315 This function will not return None since Notmuch ensures that every
316 message belongs to a single thread.
318 :returns: String with a thread ID
319 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
322 if self._msg is None:
323 raise NotmuchError(STATUS.NOT_INITIALIZED)
325 return Message._get_thread_id (self._msg);
327 def get_replies(self):
328 """Gets all direct replies to this message as :class:`Messages` iterator
330 .. note:: This call only makes sense if 'message' was
331 ultimately obtained from a :class:`Thread` object, (such as
332 by coming directly from the result of calling
333 :meth:`Thread.get_toplevel_messages` or by any number of
334 subsequent calls to :meth:`get_replies`). If this message was
335 obtained through some non-thread means, (such as by a call
336 to :meth:`Query.search_messages`), then this function will
339 :returns: :class:`Messages` or `None` if there are no replies to
341 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
344 if self._msg is None:
345 raise NotmuchError(STATUS.NOT_INITIALIZED)
347 msgs_p = Message._get_replies(self._msg);
352 return Messages(msgs_p,self)
355 """Returns time_t of the message date
357 For the original textual representation of the Date header from the
358 message call notmuch_message_get_header() with a header value of
361 :returns: A time_t timestamp.
363 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
366 if self._msg is None:
367 raise NotmuchError(STATUS.NOT_INITIALIZED)
368 return Message._get_date(self._msg)
370 def get_header(self, header):
371 """Returns a message header
373 This returns any message header that is stored in the notmuch database.
374 This is only a selected subset of headers, which is currently:
376 TODO: add stored headers
378 :param header: The name of the header to be retrieved.
379 It is not case-sensitive (TODO: confirm).
381 :returns: The header value as string
382 :exception: :exc:`NotmuchError`
384 * STATUS.NOT_INITIALIZED if the message
386 * STATUS.NULL_POINTER, if no header was found
388 if self._msg is None:
389 raise NotmuchError(STATUS.NOT_INITIALIZED)
391 #Returns NULL if any error occurs.
392 header = Message._get_header (self._msg, header)
394 raise NotmuchError(STATUS.NULL_POINTER)
397 def get_filename(self):
398 """Returns the file path of the message file
400 :returns: Absolute file path & name of the message file
401 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
404 if self._msg is None:
405 raise NotmuchError(STATUS.NOT_INITIALIZED)
406 return Message._get_filename(self._msg)
408 def get_filenames(self):
409 """Get all filenames for the email corresponding to 'message'
411 Returns a Filenames() generator with all absolute filepaths for
412 messages recorded to have the same Message-ID. These files must
413 not necessarily have identical content."""
414 if self._msg is None:
415 raise NotmuchError(STATUS.NOT_INITIALIZED)
417 files_p = Message._get_filenames(self._msg)
419 return Filenames(files_p, self).as_generator()
421 def get_flag(self, flag):
422 """Checks whether a specific flag is set for this message
424 The method :meth:`Query.search_threads` sets
425 *Message.FLAG.MATCH* for those messages that match the
426 query. This method allows us to get the value of this flag.
428 :param flag: One of the :attr:`Message.FLAG` values (currently only
430 :returns: An unsigned int (0/1), indicating whether the flag is set.
431 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
434 if self._msg is None:
435 raise NotmuchError(STATUS.NOT_INITIALIZED)
436 return Message._get_flag(self._msg, flag)
438 def set_flag(self, flag, value):
439 """Sets/Unsets a specific flag for this message
441 :param flag: One of the :attr:`Message.FLAG` values (currently only
443 :param value: A bool indicating whether to set or unset the flag.
446 :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message
449 if self._msg is None:
450 raise NotmuchError(STATUS.NOT_INITIALIZED)
451 nmlib.notmuch_message_set_flag(self._msg, flag, value)
454 """Returns the message tags
456 :returns: A :class:`Tags` iterator.
457 :exception: :exc:`NotmuchError`
459 * STATUS.NOT_INITIALIZED if the message
461 * STATUS.NULL_POINTER, on error
463 if self._msg is None:
464 raise NotmuchError(STATUS.NOT_INITIALIZED)
466 tags_p = Message._get_tags(self._msg)
468 raise NotmuchError(STATUS.NULL_POINTER)
469 return Tags(tags_p, self)
471 def add_tag(self, tag):
472 """Adds a tag to the given message
474 Adds a tag to the current message. The maximal tag length is defined in
475 the notmuch library and is currently 200 bytes.
477 :param tag: String with a 'tag' to be added.
478 :returns: STATUS.SUCCESS if the tag was successfully added.
479 Raises an exception otherwise.
480 :exception: :exc:`NotmuchError`. They have the following meaning:
483 The 'tag' argument is NULL
485 The length of 'tag' is too long
486 (exceeds Message.NOTMUCH_TAG_MAX)
487 STATUS.READ_ONLY_DATABASE
488 Database was opened in read-only mode so message cannot be
490 STATUS.NOT_INITIALIZED
491 The message has not been initialized.
493 if self._msg is None:
494 raise NotmuchError(STATUS.NOT_INITIALIZED)
496 status = nmlib.notmuch_message_add_tag (self._msg, tag)
498 if STATUS.SUCCESS == status:
502 raise NotmuchError(status)
504 def remove_tag(self, tag):
505 """Removes a tag from the given message
507 If the message has no such tag, this is a non-operation and
508 will report success anyway.
510 :param tag: String with a 'tag' to be removed.
511 :returns: STATUS.SUCCESS if the tag was successfully removed or if
512 the message had no such tag.
513 Raises an exception otherwise.
514 :exception: :exc:`NotmuchError`. They have the following meaning:
517 The 'tag' argument is NULL
519 The length of 'tag' is too long
520 (exceeds NOTMUCH_TAG_MAX)
521 STATUS.READ_ONLY_DATABASE
522 Database was opened in read-only mode so message cannot
524 STATUS.NOT_INITIALIZED
525 The message has not been initialized.
527 if self._msg is None:
528 raise NotmuchError(STATUS.NOT_INITIALIZED)
530 status = nmlib.notmuch_message_remove_tag(self._msg, tag)
532 if STATUS.SUCCESS == status:
536 raise NotmuchError(status)
538 def remove_all_tags(self):
539 """Removes all tags from the given message.
541 See :meth:`freeze` for an example showing how to safely
544 :returns: STATUS.SUCCESS if the tags were successfully removed.
545 Raises an exception otherwise.
546 :exception: :exc:`NotmuchError`. They have the following meaning:
548 STATUS.READ_ONLY_DATABASE
549 Database was opened in read-only mode so message cannot
551 STATUS.NOT_INITIALIZED
552 The message has not been initialized.
554 if self._msg is None:
555 raise NotmuchError(STATUS.NOT_INITIALIZED)
557 status = nmlib.notmuch_message_remove_all_tags(self._msg)
559 if STATUS.SUCCESS == status:
563 raise NotmuchError(status)
566 """Freezes the current state of 'message' within the database
568 This means that changes to the message state, (via :meth:`add_tag`,
569 :meth:`remove_tag`, and :meth:`remove_all_tags`), will not be
570 committed to the database until the message is :meth:`thaw`ed.
572 Multiple calls to freeze/thaw are valid and these calls will
573 "stack". That is there must be as many calls to thaw as to freeze
574 before a message is actually thawed.
576 The ability to do freeze/thaw allows for safe transactions to
577 change tag values. For example, explicitly setting a message to
578 have a given set of tags might look like this::
581 msg.remove_all_tags()
586 With freeze/thaw used like this, the message in the database is
587 guaranteed to have either the full set of original tag values, or
588 the full set of new tag values, but nothing in between.
590 Imagine the example above without freeze/thaw and the operation
591 somehow getting interrupted. This could result in the message being
592 left with no tags if the interruption happened after
593 :meth:`remove_all_tags` but before :meth:`add_tag`.
595 :returns: STATUS.SUCCESS if the message was successfully frozen.
596 Raises an exception otherwise.
597 :exception: :exc:`NotmuchError`. They have the following meaning:
599 STATUS.READ_ONLY_DATABASE
600 Database was opened in read-only mode so message cannot
602 STATUS.NOT_INITIALIZED
603 The message has not been initialized.
605 if self._msg is None:
606 raise NotmuchError(STATUS.NOT_INITIALIZED)
608 status = nmlib.notmuch_message_freeze(self._msg)
610 if STATUS.SUCCESS == status:
614 raise NotmuchError(status)
617 """Thaws the current 'message'
619 Thaw the current 'message', synchronizing any changes that may have
620 occurred while 'message' was frozen into the notmuch database.
622 See :meth:`freeze` for an example of how to use this
623 function to safely provide tag changes.
625 Multiple calls to freeze/thaw are valid and these calls with
626 "stack". That is there must be as many calls to thaw as to freeze
627 before a message is actually thawed.
629 :returns: STATUS.SUCCESS if the message was successfully frozen.
630 Raises an exception otherwise.
631 :exception: :exc:`NotmuchError`. They have the following meaning:
633 STATUS.UNBALANCED_FREEZE_THAW
634 An attempt was made to thaw an unfrozen message.
635 That is, there have been an unbalanced number of calls
636 to :meth:`freeze` and :meth:`thaw`.
637 STATUS.NOT_INITIALIZED
638 The message has not been initialized.
640 if self._msg is None:
641 raise NotmuchError(STATUS.NOT_INITIALIZED)
643 status = nmlib.notmuch_message_thaw(self._msg)
645 if STATUS.SUCCESS == status:
649 raise NotmuchError(status)
653 """(Not implemented)"""
654 return self.get_flag(Message.FLAG.MATCH)
657 """A message() is represented by a 1-line summary"""
659 msg['from'] = self.get_header('from')
660 msg['tags'] = str(self.get_tags())
661 msg['date'] = date.fromtimestamp(self.get_date())
662 replies = self.get_replies()
663 msg['replies'] = len(replies) if replies is not None else -1
664 return "%(from)s (%(date)s) (%(tags)s) (%(replies)d) replies" % (msg)
667 def get_message_parts(self):
668 """Output like notmuch show"""
669 fp = open(self.get_filename())
670 email_msg = email.message_from_file(fp)
674 for msg in email_msg.walk():
675 if not msg.is_multipart():
679 def get_part(self, num):
680 """Returns the nth message body part"""
681 parts = self.get_message_parts()
682 if (num <= 0 or num > len(parts)):
685 out_part = parts[(num - 1)]
686 return out_part.get_payload(decode=True)
688 def format_message_internal(self):
689 """Create an internal representation of the message parts,
690 which can easily be output to json, text, or another output
691 format. The argument match tells whether this matched a
694 output["id"] = self.get_message_id()
695 output["match"] = self.is_match()
696 output["filename"] = self.get_filename()
697 output["tags"] = list(self.get_tags())
700 for h in ["Subject", "From", "To", "Cc", "Bcc", "Date"]:
701 headers[h] = self.get_header(h)
702 output["headers"] = headers
705 parts = self.get_message_parts()
706 for i in xrange(len(parts)):
709 part_dict["id"] = i + 1
710 # We'll be using this is a lot, so let's just get it once.
711 cont_type = msg.get_content_type()
712 part_dict["content-type"] = cont_type
714 # Now we emulate the current behaviour, where it ignores
715 # the html if there's a text representation.
717 # This is being worked on, but it will be easier to fix
718 # here in the future than to end up with another
719 # incompatible solution.
720 disposition = msg["Content-Disposition"]
721 if disposition and disposition.lower().startswith("attachment"):
722 part_dict["filename"] = msg.get_filename()
724 if cont_type.lower() == "text/plain":
725 part_dict["content"] = msg.get_payload()
726 elif (cont_type.lower() == "text/html" and
728 part_dict["content"] = msg.get_payload()
729 body.append(part_dict)
731 output["body"] = body
735 def format_message_as_json(self, indent=0):
736 """Outputs the message as json. This is essentially the same
737 as python's dict format, but we run it through, just so we
738 don't have to worry about the details."""
739 return json.dumps(self.format_message_internal())
741 def format_message_as_text(self, indent=0):
742 """Outputs it in the old-fashioned notmuch text form. Will be
743 easy to change to a new format when the format changes."""
745 format = self.format_message_internal()
746 output = "\fmessage{ id:%s depth:%d match:%d filename:%s" \
747 % (format['id'], indent, format['match'], format['filename'])
748 output += "\n\fheader{"
750 #Todo: this date is supposed to be prettified, as in the index.
751 output += "\n%s (%s) (" % (format["headers"]["From"],
752 format["headers"]["Date"])
753 output += ", ".join(format["tags"])
756 output += "\nSubject: %s" % format["headers"]["Subject"]
757 output += "\nFrom: %s" % format["headers"]["From"]
758 output += "\nTo: %s" % format["headers"]["To"]
759 if format["headers"]["Cc"]:
760 output += "\nCc: %s" % format["headers"]["Cc"]
761 if format["headers"]["Bcc"]:
762 output += "\nBcc: %s" % format["headers"]["Bcc"]
763 output += "\nDate: %s" % format["headers"]["Date"]
764 output += "\n\fheader}"
766 output += "\n\fbody{"
768 parts = format["body"]
769 parts.sort(key=lambda x: x['id'])
771 if not p.has_key("filename"):
772 output += "\n\fpart{ "
773 output += "ID: %d, Content-type: %s\n" % (p["id"],
775 if p.has_key("content"):
776 output += "\n%s\n" % p["content"]
778 output += "Non-text part: %s\n" % p["content-type"]
779 output += "\n\fpart}"
781 output += "\n\fattachment{ "
782 output += "ID: %d, Content-type:%s\n" % (p["id"],
784 output += "Attachment: %s\n" % p["filename"]
785 output += "\n\fattachment}\n"
787 output += "\n\fbody}\n"
788 output += "\n\fmessage}"
793 """Close and free the notmuch Message"""
794 if self._msg is not None:
795 nmlib.notmuch_message_destroy (self._msg)