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_long, c_uint, c_int
23 from datetime import date
24 from .globals import (
39 from .filenames import Filenames
43 import simplejson as json
48 class Message(Python3StringMixIn):
49 """Represents a single Email message
51 Technically, this wraps the underlying *notmuch_message_t*
52 structure. A user will usually not create these objects themselves
53 but get them as search results.
55 As it implements :meth:`__cmp__`, it is possible to compare two
56 :class:`Message`\s using `if msg1 == msg2: ...`.
59 """notmuch_message_get_filename (notmuch_message_t *message)"""
60 _get_filename = nmlib.notmuch_message_get_filename
61 _get_filename.argtypes = [NotmuchMessageP]
62 _get_filename.restype = c_char_p
64 """return all filenames for a message"""
65 _get_filenames = nmlib.notmuch_message_get_filenames
66 _get_filenames.argtypes = [NotmuchMessageP]
67 _get_filenames.restype = NotmuchFilenamesP
69 """notmuch_message_get_flag"""
70 _get_flag = nmlib.notmuch_message_get_flag
71 _get_flag.argtypes = [NotmuchMessageP, c_uint]
72 _get_flag.restype = bool
74 """notmuch_message_set_flag"""
75 _set_flag = nmlib.notmuch_message_set_flag
76 _set_flag.argtypes = [NotmuchMessageP, c_uint, c_int]
77 _set_flag.restype = None
79 """notmuch_message_get_message_id (notmuch_message_t *message)"""
80 _get_message_id = nmlib.notmuch_message_get_message_id
81 _get_message_id.argtypes = [NotmuchMessageP]
82 _get_message_id.restype = c_char_p
84 """notmuch_message_get_thread_id"""
85 _get_thread_id = nmlib.notmuch_message_get_thread_id
86 _get_thread_id.argtypes = [NotmuchMessageP]
87 _get_thread_id.restype = c_char_p
89 """notmuch_message_get_replies"""
90 _get_replies = nmlib.notmuch_message_get_replies
91 _get_replies.argtypes = [NotmuchMessageP]
92 _get_replies.restype = NotmuchMessagesP
94 """notmuch_message_get_tags (notmuch_message_t *message)"""
95 _get_tags = nmlib.notmuch_message_get_tags
96 _get_tags.argtypes = [NotmuchMessageP]
97 _get_tags.restype = NotmuchTagsP
99 _get_date = nmlib.notmuch_message_get_date
100 _get_date.argtypes = [NotmuchMessageP]
101 _get_date.restype = c_long
103 _get_header = nmlib.notmuch_message_get_header
104 _get_header.argtypes = [NotmuchMessageP, c_char_p]
105 _get_header.restype = c_char_p
107 """notmuch_status_t ..._maildir_flags_to_tags (notmuch_message_t *)"""
108 _tags_to_maildir_flags = nmlib.notmuch_message_tags_to_maildir_flags
109 _tags_to_maildir_flags.argtypes = [NotmuchMessageP]
110 _tags_to_maildir_flags.restype = c_int
112 """notmuch_status_t ..._tags_to_maildir_flags (notmuch_message_t *)"""
113 _maildir_flags_to_tags = nmlib.notmuch_message_maildir_flags_to_tags
114 _maildir_flags_to_tags.argtypes = [NotmuchMessageP]
115 _maildir_flags_to_tags.restype = c_int
117 #Constants: Flags that can be set/get with set_flag
118 FLAG = Enum(['MATCH'])
120 def __init__(self, msg_p, parent=None):
122 :param msg_p: A pointer to an internal notmuch_message_t
123 Structure. If it is `None`, we will raise an
124 :exc:`NullPointerError`.
126 :param parent: A 'parent' object is passed which this message is
127 derived from. We save a reference to it, so we can
128 automatically delete the parent object once all derived
132 raise NullPointerError()
134 #keep reference to parent, so we keep it alive
135 self._parent = parent
137 def get_message_id(self):
138 """Returns the message ID
140 :returns: String with a message ID
141 :raises: :exc:`NotInitializedError` if the message
145 raise NotInitializedError()
146 return Message._get_message_id(self._msg).decode('utf-8', 'ignore')
148 def get_thread_id(self):
149 """Returns the thread ID
151 The returned string belongs to 'message' will only be valid for as
152 long as the message is valid.
154 This function will not return `None` since Notmuch ensures that every
155 message belongs to a single thread.
157 :returns: String with a thread ID
158 :raises: :exc:`NotInitializedError` if the message
162 raise NotInitializedError()
164 return Message._get_thread_id(self._msg).decode('utf-8', 'ignore')
166 def get_replies(self):
167 """Gets all direct replies to this message as :class:`Messages`
172 This call only makes sense if 'message' was ultimately obtained from
173 a :class:`Thread` object, (such as by coming directly from the
174 result of calling :meth:`Thread.get_toplevel_messages` or by any
175 number of subsequent calls to :meth:`get_replies`). If this message
176 was obtained through some non-thread means, (such as by a call to
177 :meth:`Query.search_messages`), then this function will return
178 an empty Messages iterator.
180 :returns: :class:`Messages`.
181 :raises: :exc:`NotInitializedError` if the message
185 raise NotInitializedError()
187 msgs_p = Message._get_replies(self._msg)
189 from .messages import Messages, EmptyMessagesResult
192 return EmptyMessagesResult(self)
194 return Messages(msgs_p, self)
197 """Returns time_t of the message date
199 For the original textual representation of the Date header from the
200 message call notmuch_message_get_header() with a header value of
203 :returns: A time_t timestamp.
205 :raises: :exc:`NotInitializedError` if the message
209 raise NotInitializedError()
210 return Message._get_date(self._msg)
212 def get_header(self, header):
213 """Get the value of the specified header.
215 The value will be read from the actual message file, not from
216 the notmuch database. The header name is case insensitive.
218 Returns an empty string ("") if the message does not contain a
219 header line matching 'header'.
221 :param header: The name of the header to be retrieved.
222 It is not case-sensitive.
224 :returns: The header value as string
225 :raises: :exc:`NotInitializedError` if the message is not
227 :raises: :exc:`NullPointerError` if any error occured
230 raise NotInitializedError()
232 #Returns NULL if any error occurs.
233 header = Message._get_header(self._msg, _str(header))
235 raise NullPointerError()
236 return header.decode('UTF-8', 'ignore')
238 def get_filename(self):
239 """Returns the file path of the message file
241 :returns: Absolute file path & name of the message file
242 :raises: :exc:`NotInitializedError` if the message
246 raise NotInitializedError()
247 return Message._get_filename(self._msg).decode('utf-8', 'ignore')
249 def get_filenames(self):
250 """Get all filenames for the email corresponding to 'message'
252 Returns a Filenames() generator with all absolute filepaths for
253 messages recorded to have the same Message-ID. These files must
254 not necessarily have identical content."""
256 raise NotInitializedError()
258 files_p = Message._get_filenames(self._msg)
260 return Filenames(files_p, self).as_generator()
262 def get_flag(self, flag):
263 """Checks whether a specific flag is set for this message
265 The method :meth:`Query.search_threads` sets
266 *Message.FLAG.MATCH* for those messages that match the
267 query. This method allows us to get the value of this flag.
269 :param flag: One of the :attr:`Message.FLAG` values (currently only
271 :returns: An unsigned int (0/1), indicating whether the flag is set.
272 :raises: :exc:`NotInitializedError` if the message
276 raise NotInitializedError()
277 return Message._get_flag(self._msg, flag)
279 def set_flag(self, flag, value):
280 """Sets/Unsets a specific flag for this message
282 :param flag: One of the :attr:`Message.FLAG` values (currently only
284 :param value: A bool indicating whether to set or unset the flag.
286 :raises: :exc:`NotInitializedError` if the message
290 raise NotInitializedError()
291 self._set_flag(self._msg, flag, value)
294 """Returns the message tags
296 :returns: A :class:`Tags` iterator.
297 :raises: :exc:`NotInitializedError` if the message is not
299 :raises: :exc:`NullPointerError` if any error occured
302 raise NotInitializedError()
304 tags_p = Message._get_tags(self._msg)
306 raise NullPointerError()
307 return Tags(tags_p, self)
309 _add_tag = nmlib.notmuch_message_add_tag
310 _add_tag.argtypes = [NotmuchMessageP, c_char_p]
311 _add_tag.restype = c_uint
313 def add_tag(self, tag, sync_maildir_flags=False):
314 """Adds a tag to the given message
316 Adds a tag to the current message. The maximal tag length is defined in
317 the notmuch library and is currently 200 bytes.
319 :param tag: String with a 'tag' to be added.
321 :param sync_maildir_flags: If notmuch configuration is set to do
322 this, add maildir flags corresponding to notmuch tags. See
323 underlying method :meth:`tags_to_maildir_flags`. Use False
324 if you want to add/remove many tags on a message without
325 having to physically rename the file every time. Do note,
326 that this will do nothing when a message is frozen, as tag
327 changes will not be committed to the database yet.
329 :returns: STATUS.SUCCESS if the tag was successfully added.
330 Raises an exception otherwise.
331 :raises: :exc:`NullPointerError` if the `tag` argument is NULL
332 :raises: :exc:`TagTooLongError` if the length of `tag` exceeds
333 Message.NOTMUCH_TAG_MAX)
334 :raises: :exc:`ReadOnlyDatabaseError` if the database was opened
335 in read-only mode so message cannot be modified
336 :raises: :exc:`NotInitializedError` if message has not been
340 raise NotInitializedError()
342 status = self._add_tag(self._msg, _str(tag))
344 # bail out on failure
345 if status != STATUS.SUCCESS:
346 raise NotmuchError(status)
348 if sync_maildir_flags:
349 self.tags_to_maildir_flags()
350 return STATUS.SUCCESS
352 _remove_tag = nmlib.notmuch_message_remove_tag
353 _remove_tag.argtypes = [NotmuchMessageP, c_char_p]
354 _remove_tag.restype = c_uint
356 def remove_tag(self, tag, sync_maildir_flags=False):
357 """Removes a tag from the given message
359 If the message has no such tag, this is a non-operation and
360 will report success anyway.
362 :param tag: String with a 'tag' to be removed.
363 :param sync_maildir_flags: If notmuch configuration is set to do
364 this, add maildir flags corresponding to notmuch tags. See
365 underlying method :meth:`tags_to_maildir_flags`. Use False
366 if you want to add/remove many tags on a message without
367 having to physically rename the file every time. Do note,
368 that this will do nothing when a message is frozen, as tag
369 changes will not be committed to the database yet.
371 :returns: STATUS.SUCCESS if the tag was successfully removed or if
372 the message had no such tag.
373 Raises an exception otherwise.
374 :raises: :exc:`NullPointerError` if the `tag` argument is NULL
375 :raises: :exc:`TagTooLongError` if the length of `tag` exceeds
376 Message.NOTMUCH_TAG_MAX)
377 :raises: :exc:`ReadOnlyDatabaseError` if the database was opened
378 in read-only mode so message cannot be modified
379 :raises: :exc:`NotInitializedError` if message has not been
383 raise NotInitializedError()
385 status = self._remove_tag(self._msg, _str(tag))
387 if status != STATUS.SUCCESS:
388 raise NotmuchError(status)
390 if sync_maildir_flags:
391 self.tags_to_maildir_flags()
392 return STATUS.SUCCESS
394 _remove_all_tags = nmlib.notmuch_message_remove_all_tags
395 _remove_all_tags.argtypes = [NotmuchMessageP]
396 _remove_all_tags.restype = c_uint
398 def remove_all_tags(self, sync_maildir_flags=False):
399 """Removes all tags from the given message.
401 See :meth:`freeze` for an example showing how to safely
405 :param sync_maildir_flags: If notmuch configuration is set to do
406 this, add maildir flags corresponding to notmuch tags. See
407 :meth:`tags_to_maildir_flags`. Use False if you want to
408 add/remove many tags on a message without having to
409 physically rename the file every time. Do note, that this
410 will do nothing when a message is frozen, as tag changes
411 will not be committed to the database yet.
413 :returns: STATUS.SUCCESS if the tags were successfully removed.
414 Raises an exception otherwise.
415 :raises: :exc:`ReadOnlyDatabaseError` if the database was opened
416 in read-only mode so message cannot be modified
417 :raises: :exc:`NotInitializedError` if message has not been
421 raise NotInitializedError()
423 status = self._remove_all_tags(self._msg)
426 if status != STATUS.SUCCESS:
427 raise NotmuchError(status)
429 if sync_maildir_flags:
430 self.tags_to_maildir_flags()
431 return STATUS.SUCCESS
433 _freeze = nmlib.notmuch_message_freeze
434 _freeze.argtypes = [NotmuchMessageP]
435 _freeze.restype = c_uint
438 """Freezes the current state of 'message' within the database
440 This means that changes to the message state, (via :meth:`add_tag`,
441 :meth:`remove_tag`, and :meth:`remove_all_tags`), will not be
442 committed to the database until the message is :meth:`thaw` ed.
444 Multiple calls to freeze/thaw are valid and these calls will
445 "stack". That is there must be as many calls to thaw as to freeze
446 before a message is actually thawed.
448 The ability to do freeze/thaw allows for safe transactions to
449 change tag values. For example, explicitly setting a message to
450 have a given set of tags might look like this::
453 msg.remove_all_tags(False)
455 msg.add_tag(tag, False)
457 msg.tags_to_maildir_flags()
459 With freeze/thaw used like this, the message in the database is
460 guaranteed to have either the full set of original tag values, or
461 the full set of new tag values, but nothing in between.
463 Imagine the example above without freeze/thaw and the operation
464 somehow getting interrupted. This could result in the message being
465 left with no tags if the interruption happened after
466 :meth:`remove_all_tags` but before :meth:`add_tag`.
468 :returns: STATUS.SUCCESS if the message was successfully frozen.
469 Raises an exception otherwise.
470 :raises: :exc:`ReadOnlyDatabaseError` if the database was opened
471 in read-only mode so message cannot be modified
472 :raises: :exc:`NotInitializedError` if message has not been
476 raise NotInitializedError()
478 status = self._freeze(self._msg)
480 if STATUS.SUCCESS == status:
484 raise NotmuchError(status)
486 _thaw = nmlib.notmuch_message_thaw
487 _thaw.argtypes = [NotmuchMessageP]
488 _thaw.restype = c_uint
491 """Thaws the current 'message'
493 Thaw the current 'message', synchronizing any changes that may have
494 occurred while 'message' was frozen into the notmuch database.
496 See :meth:`freeze` for an example of how to use this
497 function to safely provide tag changes.
499 Multiple calls to freeze/thaw are valid and these calls with
500 "stack". That is there must be as many calls to thaw as to freeze
501 before a message is actually thawed.
503 :returns: STATUS.SUCCESS if the message was successfully frozen.
504 Raises an exception otherwise.
505 :raises: :exc:`UnbalancedFreezeThawError` if an attempt was made
506 to thaw an unfrozen message. That is, there have been
507 an unbalanced number of calls to :meth:`freeze` and
509 :raises: :exc:`NotInitializedError` if message has not been
513 raise NotInitializedError()
515 status = self._thaw(self._msg)
517 if STATUS.SUCCESS == status:
521 raise NotmuchError(status)
524 """(Not implemented)"""
525 return self.get_flag(Message.FLAG.MATCH)
527 def tags_to_maildir_flags(self):
528 """Synchronize notmuch tags to file Maildir flags
530 'D' if the message has the "draft" tag
531 'F' if the message has the "flagged" tag
532 'P' if the message has the "passed" tag
533 'R' if the message has the "replied" tag
534 'S' if the message does not have the "unread" tag
536 Any existing flags unmentioned in the list above will be
537 preserved in the renaming.
539 Also, if this filename is in a directory named "new", rename it
540 to be within the neighboring directory named "cur".
542 Do note that calling this method while a message is frozen might
543 not work yet, as the modified tags have not been committed yet
546 :returns: a :class:`STATUS` value. In short, you want to see
547 notmuch.STATUS.SUCCESS here. See there for details."""
549 raise NotInitializedError()
550 return Message._tags_to_maildir_flags(self._msg)
552 def maildir_flags_to_tags(self):
553 """Synchronize file Maildir flags to notmuch tags
555 Flag Action if present
556 ---- -----------------
557 'D' Adds the "draft" tag to the message
558 'F' Adds the "flagged" tag to the message
559 'P' Adds the "passed" tag to the message
560 'R' Adds the "replied" tag to the message
561 'S' Removes the "unread" tag from the message
563 For each flag that is not present, the opposite action
564 (add/remove) is performed for the corresponding tags. If there
565 are multiple filenames associated with this message, the flag is
566 considered present if it appears in one or more filenames. (That
567 is, the flags from the multiple filenames are combined with the
568 logical OR operator.)
570 As a convenience, you can set the sync_maildir_flags parameter in
571 :meth:`Database.add_message` to implicitly call this.
573 :returns: a :class:`STATUS`. In short, you want to see
574 notmuch.STATUS.SUCCESS here. See there for details."""
576 raise NotInitializedError()
577 return Message._tags_to_maildir_flags(self._msg)
580 """Represent a Message() object by str()"""
581 return self.__str__()
583 def __unicode__(self):
584 format = "%s (%s) (%s)"
585 return format % (self.get_header('from'),
587 date.fromtimestamp(self.get_date()),
590 def get_message_parts(self):
591 """Output like notmuch show"""
592 fp = open(self.get_filename())
593 email_msg = email.message_from_file(fp)
597 for msg in email_msg.walk():
598 if not msg.is_multipart():
602 def get_part(self, num):
603 """Returns the nth message body part"""
604 parts = self.get_message_parts()
605 if (num <= 0 or num > len(parts)):
608 out_part = parts[(num - 1)]
609 return out_part.get_payload(decode=True)
611 def format_message_internal(self):
612 """Create an internal representation of the message parts,
613 which can easily be output to json, text, or another output
614 format. The argument match tells whether this matched a
617 output["id"] = self.get_message_id()
618 output["match"] = self.is_match()
619 output["filename"] = self.get_filename()
620 output["tags"] = list(self.get_tags())
623 for h in ["Subject", "From", "To", "Cc", "Bcc", "Date"]:
624 headers[h] = self.get_header(h)
625 output["headers"] = headers
628 parts = self.get_message_parts()
629 for i in xrange(len(parts)):
632 part_dict["id"] = i + 1
633 # We'll be using this is a lot, so let's just get it once.
634 cont_type = msg.get_content_type()
635 part_dict["content-type"] = cont_type
637 # Now we emulate the current behaviour, where it ignores
638 # the html if there's a text representation.
640 # This is being worked on, but it will be easier to fix
641 # here in the future than to end up with another
642 # incompatible solution.
643 disposition = msg["Content-Disposition"]
644 if disposition and disposition.lower().startswith("attachment"):
645 part_dict["filename"] = msg.get_filename()
647 if cont_type.lower() == "text/plain":
648 part_dict["content"] = msg.get_payload()
649 elif (cont_type.lower() == "text/html" and
651 part_dict["content"] = msg.get_payload()
652 body.append(part_dict)
654 output["body"] = body
658 def format_message_as_json(self, indent=0):
659 """Outputs the message as json. This is essentially the same
660 as python's dict format, but we run it through, just so we
661 don't have to worry about the details."""
662 return json.dumps(self.format_message_internal())
664 def format_message_as_text(self, indent=0):
665 """Outputs it in the old-fashioned notmuch text form. Will be
666 easy to change to a new format when the format changes."""
668 format = self.format_message_internal()
669 output = "\fmessage{ id:%s depth:%d match:%d filename:%s" \
670 % (format['id'], indent, format['match'], format['filename'])
671 output += "\n\fheader{"
673 #Todo: this date is supposed to be prettified, as in the index.
674 output += "\n%s (%s) (" % (format["headers"]["From"],
675 format["headers"]["Date"])
676 output += ", ".join(format["tags"])
679 output += "\nSubject: %s" % format["headers"]["Subject"]
680 output += "\nFrom: %s" % format["headers"]["From"]
681 output += "\nTo: %s" % format["headers"]["To"]
682 if format["headers"]["Cc"]:
683 output += "\nCc: %s" % format["headers"]["Cc"]
684 if format["headers"]["Bcc"]:
685 output += "\nBcc: %s" % format["headers"]["Bcc"]
686 output += "\nDate: %s" % format["headers"]["Date"]
687 output += "\n\fheader}"
689 output += "\n\fbody{"
691 parts = format["body"]
692 parts.sort(key=lambda x: x['id'])
694 if not "filename" in p:
695 output += "\n\fpart{ "
696 output += "ID: %d, Content-type: %s\n" % (p["id"],
699 output += "\n%s\n" % p["content"]
701 output += "Non-text part: %s\n" % p["content-type"]
702 output += "\n\fpart}"
704 output += "\n\fattachment{ "
705 output += "ID: %d, Content-type:%s\n" % (p["id"],
707 output += "Attachment: %s\n" % p["filename"]
708 output += "\n\fattachment}\n"
710 output += "\n\fbody}\n"
711 output += "\n\fmessage}"
716 """Implement hash(), so we can use Message() sets"""
717 file = self.get_filename()
722 def __cmp__(self, other):
723 """Implement cmp(), so we can compare Message()s
725 2 messages are considered equal if they point to the same
726 Message-Id and if they point to the same file names. If 2
727 Messages derive from different queries where some files have
728 been added or removed, the same messages would not be considered
729 equal (as they do not point to the same set of files
731 res = cmp(self.get_message_id(), other.get_message_id())
733 res = cmp(list(self.get_filenames()), list(other.get_filenames()))
736 _destroy = nmlib.notmuch_message_destroy
737 _destroy.argtypes = [NotmuchMessageP]
738 _destroy.restype = None
741 """Close and free the notmuch Message"""
742 if self._msg is not None:
743 self._destroy(self._msg)