b291f9fa4e8748c87b2c36912e33e47726a432f3
[notmuch] / bindings / python / notmuch / message.py
1 """
2 This file is part of notmuch.
3
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.
8
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
12 for more details.
13
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/>.
16
17 Copyright 2010 Sebastian Spaeth <Sebastian@SSpaeth.de>'
18                Jesse Rosenthal <jrosenthal@jhu.edu>
19 """
20
21
22 from ctypes import c_char_p, c_long, c_uint, c_int
23 from datetime import date
24 from notmuch.globals import (
25     nmlib,
26     Enum,
27     _str,
28     Python3StringMixIn,
29     STATUS,
30     NotmuchError,
31     NullPointerError,
32     NotInitializedError,
33     NotmuchTagsP,
34     NotmuchMessageP,
35     NotmuchMessagesP,
36     NotmuchFilenamesP,
37 )
38 from notmuch.tag import Tags
39 from notmuch.filename import Filenames
40 import sys
41 import email
42 try:
43     import simplejson as json
44 except ImportError:
45     import json
46
47
48 class Messages(object):
49     """Represents a list of notmuch messages
50
51     This object provides an iterator over a list of notmuch messages
52     (Technically, it provides a wrapper for the underlying
53     *notmuch_messages_t* structure). Do note that the underlying library
54     only provides a one-time iterator (it cannot reset the iterator to
55     the start). Thus iterating over the function will "exhaust" the list
56     of messages, and a subsequent iteration attempt will raise a
57     :exc:`NotInitializedError`. If you need to
58     re-iterate over a list of messages you will need to retrieve a new
59     :class:`Messages` object or cache your :class:`Message`\s in a list
60     via::
61
62        msglist = list(msgs)
63
64     You can store and reuse the single :class:`Message` objects as often
65     as you want as long as you keep the parent :class:`Messages` object
66     around. (Due to hierarchical memory allocation, all derived
67     :class:`Message` objects will be invalid when we delete the parent
68     :class:`Messages` object, even if it was already exhausted.) So
69     this works::
70
71       db   = Database()
72       msgs = Query(db,'').search_messages() #get a Messages() object
73       msglist = list(msgs)
74
75       # msgs is "exhausted" now and msgs.next() will raise an exception.
76       # However it will be kept alive until all retrieved Message()
77       # objects are also deleted. If you do e.g. an explicit del(msgs)
78       # here, the following lines would fail.
79
80       # You can reiterate over *msglist* however as often as you want.
81       # It is simply a list with :class:`Message`s.
82
83       print (msglist[0].get_filename())
84       print (msglist[1].get_filename())
85       print (msglist[0].get_message_id())
86
87
88     As :class:`Message` implements both __hash__() and __cmp__(), it is
89     possible to make sets out of :class:`Messages` and use set
90     arithmetic (this happens in python and will of course be *much*
91     slower than redoing a proper query with the appropriate filters::
92
93         s1, s2 = set(msgs1), set(msgs2)
94         s.union(s2)
95         s1 -= s2
96         ...
97
98     Be careful when using set arithmetic between message sets derived
99     from different Databases (ie the same database reopened after
100     messages have changed). If messages have added or removed associated
101     files in the meantime, it is possible that the same message would be
102     considered as a different object (as it points to a different file).
103     """
104
105     #notmuch_messages_get
106     _get = nmlib.notmuch_messages_get
107     _get.argtypes = [NotmuchMessagesP]
108     _get.restype = NotmuchMessageP
109
110     _collect_tags = nmlib.notmuch_messages_collect_tags
111     _collect_tags.argtypes = [NotmuchMessagesP]
112     _collect_tags.restype = NotmuchTagsP
113
114     def __init__(self, msgs_p, parent=None):
115         """
116         :param msgs_p:  A pointer to an underlying *notmuch_messages_t*
117              structure. These are not publically exposed, so a user
118              will almost never instantiate a :class:`Messages` object
119              herself. They are usually handed back as a result,
120              e.g. in :meth:`Query.search_messages`.  *msgs_p* must be
121              valid, we will raise an :exc:`NullPointerError` if it is
122              `None`.
123         :type msgs_p: :class:`ctypes.c_void_p`
124         :param parent: The parent object
125              (ie :class:`Query`) these tags are derived from. It saves
126              a reference to it, so we can automatically delete the db
127              object once all derived objects are dead.
128         :TODO: Make the iterator work more than once and cache the tags in
129                the Python object.(?)
130         """
131         if not msgs_p:
132             raise NullPointerError()
133
134         self._msgs = msgs_p
135         #store parent, so we keep them alive as long as self  is alive
136         self._parent = parent
137
138     def collect_tags(self):
139         """Return the unique :class:`Tags` in the contained messages
140
141         :returns: :class:`Tags`
142         :exceptions: :exc:`NotInitializedError` if not init'ed
143
144         .. note::
145
146             :meth:`collect_tags` will iterate over the messages and therefore
147             will not allow further iterations.
148         """
149         if not self._msgs:
150             raise NotInitializedError()
151
152         # collect all tags (returns NULL on error)
153         tags_p = Messages._collect_tags(self._msgs)
154         #reset _msgs as we iterated over it and can do so only once
155         self._msgs = None
156
157         if tags_p == None:
158             raise NullPointerError()
159         return Tags(tags_p, self)
160
161     def __iter__(self):
162         """ Make Messages an iterator """
163         return self
164
165     _valid = nmlib.notmuch_messages_valid
166     _valid.argtypes = [NotmuchMessagesP]
167     _valid.restype = bool
168
169     _move_to_next = nmlib.notmuch_messages_move_to_next
170     _move_to_next.argtypes = [NotmuchMessagesP]
171     _move_to_next.restype = None
172
173     def __next__(self):
174         if not self._msgs:
175             raise NotInitializedError()
176
177         if not self._valid(self._msgs):
178             self._msgs = None
179             raise StopIteration
180
181         msg = Message(Messages._get(self._msgs), self)
182         self._move_to_next(self._msgs)
183         return msg
184     next = __next__ # python2.x iterator protocol compatibility
185
186     def __nonzero__(self):
187         """
188         :return: True if there is at least one more thread in the
189             Iterator, False if not."""
190         return self._msgs is not None and \
191             self._valid(self._msgs) > 0
192
193     _destroy = nmlib.notmuch_messages_destroy
194     _destroy.argtypes = [NotmuchMessagesP]
195     _destroy.restype = None
196
197     def __del__(self):
198         """Close and free the notmuch Messages"""
199         if self._msgs is not None:
200             self._destroy(self._msgs)
201
202     def format_messages(self, format, indent=0, entire_thread=False):
203         """Formats messages as needed for 'notmuch show'.
204
205         :param format: A string of either 'text' or 'json'.
206         :param indent: A number indicating the reply depth of these messages.
207         :param entire_thread: A bool, indicating whether we want to output
208                        whole threads or only the matching messages.
209         :return: a list of lines
210         """
211         result = list()
212
213         if format.lower() == "text":
214             set_start = ""
215             set_end = ""
216             set_sep = ""
217         elif format.lower() == "json":
218             set_start = "["
219             set_end = "]"
220             set_sep = ", "
221         else:
222             raise TypeError("format must be either 'text' or 'json'")
223
224         first_set = True
225
226         result.append(set_start)
227
228         # iterate through all toplevel messages in this thread
229         for msg in self:
230             # if not msg:
231             #     break
232             if not first_set:
233                 result.append(set_sep)
234             first_set = False
235
236             result.append(set_start)
237             match = msg.is_match()
238             next_indent = indent
239
240             if (match or entire_thread):
241                 if format.lower() == "text":
242                     result.append(msg.format_message_as_text(indent))
243                 else:
244                     result.append(msg.format_message_as_json(indent))
245                 next_indent = indent + 1
246
247             # get replies and print them also out (if there are any)
248             replies = msg.get_replies().format_messages(format, next_indent, entire_thread)
249             if replies:
250                 result.append(set_sep)
251                 result.extend(replies)
252
253             result.append(set_end)
254         result.append(set_end)
255
256         return result
257
258     def print_messages(self, format, indent=0, entire_thread=False, handle=sys.stdout):
259         """Outputs messages as needed for 'notmuch show' to a file like object.
260
261         :param format: A string of either 'text' or 'json'.
262         :param handle: A file like object to print to (default is sys.stdout).
263         :param indent: A number indicating the reply depth of these messages.
264         :param entire_thread: A bool, indicating whether we want to output
265                        whole threads or only the matching messages.
266         """
267         handle.write(''.join(self.format_messages(format, indent, entire_thread)))
268
269
270 class EmptyMessagesResult(Messages):
271     def __init__(self, parent):
272         self._msgs = None
273         self._parent = parent
274
275     def __next__(self):
276         raise StopIteration()
277     next = __next__
278
279
280 class Message(Python3StringMixIn):
281     """Represents a single Email message
282
283     Technically, this wraps the underlying *notmuch_message_t*
284     structure. A user will usually not create these objects themselves
285     but get them as search results.
286
287     As it implements :meth:`__cmp__`, it is possible to compare two
288     :class:`Message`\s using `if msg1 == msg2: ...`.
289     """
290
291     """notmuch_message_get_filename (notmuch_message_t *message)"""
292     _get_filename = nmlib.notmuch_message_get_filename
293     _get_filename.argtypes = [NotmuchMessageP]
294     _get_filename.restype = c_char_p
295
296     """return all filenames for a message"""
297     _get_filenames = nmlib.notmuch_message_get_filenames
298     _get_filenames.argtypes = [NotmuchMessageP]
299     _get_filenames.restype = NotmuchFilenamesP
300
301     """notmuch_message_get_flag"""
302     _get_flag = nmlib.notmuch_message_get_flag
303     _get_flag.argtypes = [NotmuchMessageP, c_uint]
304     _get_flag.restype = bool
305
306     """notmuch_message_set_flag"""
307     _set_flag = nmlib.notmuch_message_set_flag
308     _set_flag.argtypes = [NotmuchMessageP, c_uint, c_int]
309     _set_flag.restype = None
310
311     """notmuch_message_get_message_id (notmuch_message_t *message)"""
312     _get_message_id = nmlib.notmuch_message_get_message_id
313     _get_message_id.argtypes = [NotmuchMessageP]
314     _get_message_id.restype = c_char_p
315
316     """notmuch_message_get_thread_id"""
317     _get_thread_id = nmlib.notmuch_message_get_thread_id
318     _get_thread_id.argtypes = [NotmuchMessageP]
319     _get_thread_id.restype = c_char_p
320
321     """notmuch_message_get_replies"""
322     _get_replies = nmlib.notmuch_message_get_replies
323     _get_replies.argtypes = [NotmuchMessageP]
324     _get_replies.restype = NotmuchMessagesP
325
326     """notmuch_message_get_tags (notmuch_message_t *message)"""
327     _get_tags = nmlib.notmuch_message_get_tags
328     _get_tags.argtypes = [NotmuchMessageP]
329     _get_tags.restype = NotmuchTagsP
330
331     _get_date = nmlib.notmuch_message_get_date
332     _get_date.argtypes = [NotmuchMessageP]
333     _get_date.restype = c_long
334
335     _get_header = nmlib.notmuch_message_get_header
336     _get_header.argtypes = [NotmuchMessageP, c_char_p]
337     _get_header.restype = c_char_p
338
339     """notmuch_status_t ..._maildir_flags_to_tags (notmuch_message_t *)"""
340     _tags_to_maildir_flags = nmlib.notmuch_message_tags_to_maildir_flags
341     _tags_to_maildir_flags.argtypes = [NotmuchMessageP]
342     _tags_to_maildir_flags.restype = c_int
343
344     """notmuch_status_t ..._tags_to_maildir_flags (notmuch_message_t *)"""
345     _maildir_flags_to_tags = nmlib.notmuch_message_maildir_flags_to_tags
346     _maildir_flags_to_tags.argtypes = [NotmuchMessageP]
347     _maildir_flags_to_tags.restype = c_int
348
349     #Constants: Flags that can be set/get with set_flag
350     FLAG = Enum(['MATCH'])
351
352     def __init__(self, msg_p, parent=None):
353         """
354         :param msg_p: A pointer to an internal notmuch_message_t
355             Structure.  If it is `None`, we will raise an
356             :exc:`NullPointerError`.
357
358         :param parent: A 'parent' object is passed which this message is
359               derived from. We save a reference to it, so we can
360               automatically delete the parent object once all derived
361               objects are dead.
362         """
363         if not msg_p:
364             raise NullPointerError()
365         self._msg = msg_p
366         #keep reference to parent, so we keep it alive
367         self._parent = parent
368
369     def get_message_id(self):
370         """Returns the message ID
371
372         :returns: String with a message ID
373         :exception: :exc:`NotInitializedError` if the message
374                     is not initialized.
375         """
376         if not self._msg:
377             raise NotInitializedError()
378         return Message._get_message_id(self._msg).decode('utf-8', 'ignore')
379
380     def get_thread_id(self):
381         """Returns the thread ID
382
383         The returned string belongs to 'message' will only be valid for as
384         long as the message is valid.
385
386         This function will not return `None` since Notmuch ensures that every
387         message belongs to a single thread.
388
389         :returns: String with a thread ID
390         :exception: :exc:`NotInitializedError` if the message
391                     is not initialized.
392         """
393         if not self._msg:
394             raise NotInitializedError()
395
396         return Message._get_thread_id(self._msg).decode('utf-8', 'ignore')
397
398     def get_replies(self):
399         """Gets all direct replies to this message as :class:`Messages`
400         iterator
401
402         .. note::
403
404             This call only makes sense if 'message' was ultimately obtained from
405             a :class:`Thread` object, (such as by coming directly from the
406             result of calling :meth:`Thread.get_toplevel_messages` or by any
407             number of subsequent calls to :meth:`get_replies`). If this message
408             was obtained through some non-thread means, (such as by a call to
409             :meth:`Query.search_messages`), then this function will return
410             an empty Messages iterator.
411
412         :returns: :class:`Messages`.
413         :exception: :exc:`NotInitializedError` if the message
414                     is not initialized.
415         """
416         if not self._msg:
417             raise NotInitializedError()
418
419         msgs_p = Message._get_replies(self._msg)
420
421         if not msgs_p:
422             return EmptyMessagesResult(self)
423
424         return Messages(msgs_p, self)
425
426     def get_date(self):
427         """Returns time_t of the message date
428
429         For the original textual representation of the Date header from the
430         message call notmuch_message_get_header() with a header value of
431         "date".
432
433         :returns: A time_t timestamp.
434         :rtype: c_unit64
435         :exception: :exc:`NotInitializedError` if the message
436                     is not initialized.
437         """
438         if not self._msg:
439             raise NotInitializedError()
440         return Message._get_date(self._msg)
441
442     def get_header(self, header):
443         """Get the value of the specified header.
444
445         The value will be read from the actual message file, not from
446         the notmuch database. The header name is case insensitive.
447
448         Returns an empty string ("") if the message does not contain a
449         header line matching 'header'.
450
451         :param header: The name of the header to be retrieved.
452                        It is not case-sensitive.
453         :type header: str
454         :returns: The header value as string
455         :raises: :exc:`NotInitializedError` if the message is not
456                  initialized
457         :raises: :exc:`NullPointerError` if any error occured
458         """
459         if not self._msg:
460             raise NotInitializedError()
461
462         #Returns NULL if any error occurs.
463         header = Message._get_header(self._msg, _str(header))
464         if header == None:
465             raise NullPointerError()
466         return header.decode('UTF-8', 'ignore')
467
468     def get_filename(self):
469         """Returns the file path of the message file
470
471         :returns: Absolute file path & name of the message file
472         :exception: :exc:`NotInitializedError` if the message
473               is not initialized.
474         """
475         if not self._msg:
476             raise NotInitializedError()
477         return Message._get_filename(self._msg).decode('utf-8', 'ignore')
478
479     def get_filenames(self):
480         """Get all filenames for the email corresponding to 'message'
481
482         Returns a Filenames() generator with all absolute filepaths for
483         messages recorded to have the same Message-ID. These files must
484         not necessarily have identical content."""
485         if not self._msg:
486             raise NotInitializedError()
487
488         files_p = Message._get_filenames(self._msg)
489
490         return Filenames(files_p, self).as_generator()
491
492     def get_flag(self, flag):
493         """Checks whether a specific flag is set for this message
494
495         The method :meth:`Query.search_threads` sets
496         *Message.FLAG.MATCH* for those messages that match the
497         query. This method allows us to get the value of this flag.
498
499         :param flag: One of the :attr:`Message.FLAG` values (currently only
500                      *Message.FLAG.MATCH*
501         :returns: An unsigned int (0/1), indicating whether the flag is set.
502         :exception: :exc:`NotInitializedError` if the message
503               is not initialized.
504         """
505         if not self._msg:
506             raise NotInitializedError()
507         return Message._get_flag(self._msg, flag)
508
509     def set_flag(self, flag, value):
510         """Sets/Unsets a specific flag for this message
511
512         :param flag: One of the :attr:`Message.FLAG` values (currently only
513                      *Message.FLAG.MATCH*
514         :param value: A bool indicating whether to set or unset the flag.
515
516         :returns: Nothing
517         :exception: :exc:`NotInitializedError` if the message
518               is not initialized.
519         """
520         if not self._msg:
521             raise NotInitializedError()
522         self._set_flag(self._msg, flag, value)
523
524     def get_tags(self):
525         """Returns the message tags
526
527         :returns: A :class:`Tags` iterator.
528         :raises: :exc:`NotInitializedError` if the message is not
529                  initialized
530         :raises: :exc:`NullPointerError` if any error occured
531         """
532         if not self._msg:
533             raise NotInitializedError()
534
535         tags_p = Message._get_tags(self._msg)
536         if tags_p == None:
537             raise NullPointerError()
538         return Tags(tags_p, self)
539
540     _add_tag = nmlib.notmuch_message_add_tag
541     _add_tag.argtypes = [NotmuchMessageP, c_char_p]
542     _add_tag.restype = c_uint
543
544     def add_tag(self, tag, sync_maildir_flags=False):
545         """Adds a tag to the given message
546
547         Adds a tag to the current message. The maximal tag length is defined in
548         the notmuch library and is currently 200 bytes.
549
550         :param tag: String with a 'tag' to be added.
551
552         :param sync_maildir_flags: If notmuch configuration is set to do
553             this, add maildir flags corresponding to notmuch tags. See
554             underlying method :meth:`tags_to_maildir_flags`. Use False
555             if you want to add/remove many tags on a message without
556             having to physically rename the file every time. Do note,
557             that this will do nothing when a message is frozen, as tag
558             changes will not be committed to the database yet.
559
560         :returns: STATUS.SUCCESS if the tag was successfully added.
561                   Raises an exception otherwise.
562         :raises: :exc:`NullPointerError` if the `tag` argument is NULL
563         :raises: :exc:`TagTooLongError` if the length of `tag` exceeds
564                  Message.NOTMUCH_TAG_MAX)
565         :raises: :exc:`ReadOnlyDatabaseError` if the database was opened
566                  in read-only mode so message cannot be modified
567         :raises: :exc:`NotInitializedError` if message has not been
568                  initialized
569         """
570         if not self._msg:
571             raise NotInitializedError()
572
573         status = self._add_tag(self._msg, _str(tag))
574
575         # bail out on failure
576         if status != STATUS.SUCCESS:
577             raise NotmuchError(status)
578
579         if sync_maildir_flags:
580             self.tags_to_maildir_flags()
581         return STATUS.SUCCESS
582
583     _remove_tag = nmlib.notmuch_message_remove_tag
584     _remove_tag.argtypes = [NotmuchMessageP, c_char_p]
585     _remove_tag.restype = c_uint
586
587     def remove_tag(self, tag, sync_maildir_flags=False):
588         """Removes a tag from the given message
589
590         If the message has no such tag, this is a non-operation and
591         will report success anyway.
592
593         :param tag: String with a 'tag' to be removed.
594         :param sync_maildir_flags: If notmuch configuration is set to do
595             this, add maildir flags corresponding to notmuch tags. See
596             underlying method :meth:`tags_to_maildir_flags`. Use False
597             if you want to add/remove many tags on a message without
598             having to physically rename the file every time. Do note,
599             that this will do nothing when a message is frozen, as tag
600             changes will not be committed to the database yet.
601
602         :returns: STATUS.SUCCESS if the tag was successfully removed or if
603                   the message had no such tag.
604                   Raises an exception otherwise.
605         :raises: :exc:`NullPointerError` if the `tag` argument is NULL
606         :raises: :exc:`TagTooLongError` if the length of `tag` exceeds
607                  Message.NOTMUCH_TAG_MAX)
608         :raises: :exc:`ReadOnlyDatabaseError` if the database was opened
609                  in read-only mode so message cannot be modified
610         :raises: :exc:`NotInitializedError` if message has not been
611                  initialized
612         """
613         if not self._msg:
614             raise NotInitializedError()
615
616         status = self._remove_tag(self._msg, _str(tag))
617         # bail out on error
618         if status != STATUS.SUCCESS:
619             raise NotmuchError(status)
620
621         if sync_maildir_flags:
622             self.tags_to_maildir_flags()
623         return STATUS.SUCCESS
624
625     _remove_all_tags = nmlib.notmuch_message_remove_all_tags
626     _remove_all_tags.argtypes = [NotmuchMessageP]
627     _remove_all_tags.restype = c_uint
628
629     def remove_all_tags(self, sync_maildir_flags=False):
630         """Removes all tags from the given message.
631
632         See :meth:`freeze` for an example showing how to safely
633         replace tag values.
634
635
636         :param sync_maildir_flags: If notmuch configuration is set to do
637             this, add maildir flags corresponding to notmuch tags. See
638             :meth:`tags_to_maildir_flags`. Use False if you want to
639             add/remove many tags on a message without having to
640             physically rename the file every time. Do note, that this
641             will do nothing when a message is frozen, as tag changes
642             will not be committed to the database yet.
643
644         :returns: STATUS.SUCCESS if the tags were successfully removed.
645                   Raises an exception otherwise.
646         :raises: :exc:`ReadOnlyDatabaseError` if the database was opened
647                  in read-only mode so message cannot be modified
648         :raises: :exc:`NotInitializedError` if message has not been
649                  initialized
650         """
651         if not self._msg:
652             raise NotInitializedError()
653
654         status = self._remove_all_tags(self._msg)
655
656         # bail out on error
657         if status != STATUS.SUCCESS:
658             raise NotmuchError(status)
659
660         if sync_maildir_flags:
661             self.tags_to_maildir_flags()
662         return STATUS.SUCCESS
663
664     _freeze = nmlib.notmuch_message_freeze
665     _freeze.argtypes = [NotmuchMessageP]
666     _freeze.restype = c_uint
667
668     def freeze(self):
669         """Freezes the current state of 'message' within the database
670
671         This means that changes to the message state, (via :meth:`add_tag`,
672         :meth:`remove_tag`, and :meth:`remove_all_tags`), will not be
673         committed to the database until the message is :meth:`thaw` ed.
674
675         Multiple calls to freeze/thaw are valid and these calls will
676         "stack". That is there must be as many calls to thaw as to freeze
677         before a message is actually thawed.
678
679         The ability to do freeze/thaw allows for safe transactions to
680         change tag values. For example, explicitly setting a message to
681         have a given set of tags might look like this::
682
683           msg.freeze()
684           msg.remove_all_tags(False)
685           for tag in new_tags:
686               msg.add_tag(tag, False)
687           msg.thaw()
688           msg.tags_to_maildir_flags()
689
690         With freeze/thaw used like this, the message in the database is
691         guaranteed to have either the full set of original tag values, or
692         the full set of new tag values, but nothing in between.
693
694         Imagine the example above without freeze/thaw and the operation
695         somehow getting interrupted. This could result in the message being
696         left with no tags if the interruption happened after
697         :meth:`remove_all_tags` but before :meth:`add_tag`.
698
699         :returns: STATUS.SUCCESS if the message was successfully frozen.
700                   Raises an exception otherwise.
701         :raises: :exc:`ReadOnlyDatabaseError` if the database was opened
702                  in read-only mode so message cannot be modified
703         :raises: :exc:`NotInitializedError` if message has not been
704                  initialized
705         """
706         if not self._msg:
707             raise NotInitializedError()
708
709         status = self._freeze(self._msg)
710
711         if STATUS.SUCCESS == status:
712             # return on success
713             return status
714
715         raise NotmuchError(status)
716
717     _thaw = nmlib.notmuch_message_thaw
718     _thaw.argtypes = [NotmuchMessageP]
719     _thaw.restype = c_uint
720
721     def thaw(self):
722         """Thaws the current 'message'
723
724         Thaw the current 'message', synchronizing any changes that may have
725         occurred while 'message' was frozen into the notmuch database.
726
727         See :meth:`freeze` for an example of how to use this
728         function to safely provide tag changes.
729
730         Multiple calls to freeze/thaw are valid and these calls with
731         "stack". That is there must be as many calls to thaw as to freeze
732         before a message is actually thawed.
733
734         :returns: STATUS.SUCCESS if the message was successfully frozen.
735                   Raises an exception otherwise.
736         :raises: :exc:`UnbalancedFreezeThawError` if an attempt was made
737                  to thaw an unfrozen message. That is, there have been
738                  an unbalanced number of calls to :meth:`freeze` and
739                  :meth:`thaw`.
740         :raises: :exc:`NotInitializedError` if message has not been
741                  initialized
742         """
743         if not self._msg:
744             raise NotInitializedError()
745
746         status = self._thaw(self._msg)
747
748         if STATUS.SUCCESS == status:
749             # return on success
750             return status
751
752         raise NotmuchError(status)
753
754     def is_match(self):
755         """(Not implemented)"""
756         return self.get_flag(Message.FLAG.MATCH)
757
758     def tags_to_maildir_flags(self):
759         """Synchronize notmuch tags to file Maildir flags
760
761               'D' if the message has the "draft" tag
762               'F' if the message has the "flagged" tag
763               'P' if the message has the "passed" tag
764               'R' if the message has the "replied" tag
765               'S' if the message does not have the "unread" tag
766
767         Any existing flags unmentioned in the list above will be
768         preserved in the renaming.
769
770         Also, if this filename is in a directory named "new", rename it
771         to be within the neighboring directory named "cur".
772
773         Do note that calling this method while a message is frozen might
774         not work yet, as the modified tags have not been committed yet
775         to the database.
776
777         :returns: a :class:`STATUS` value. In short, you want to see
778             notmuch.STATUS.SUCCESS here. See there for details."""
779         if not self._msg:
780             raise NotInitializedError()
781         return Message._tags_to_maildir_flags(self._msg)
782
783     def maildir_flags_to_tags(self):
784         """Synchronize file Maildir flags to notmuch tags
785
786             Flag    Action if present
787             ----    -----------------
788             'D'     Adds the "draft" tag to the message
789             'F'     Adds the "flagged" tag to the message
790             'P'     Adds the "passed" tag to the message
791             'R'     Adds the "replied" tag to the message
792             'S'     Removes the "unread" tag from the message
793
794         For each flag that is not present, the opposite action
795         (add/remove) is performed for the corresponding tags.  If there
796         are multiple filenames associated with this message, the flag is
797         considered present if it appears in one or more filenames. (That
798         is, the flags from the multiple filenames are combined with the
799         logical OR operator.)
800
801         As a convenience, you can set the sync_maildir_flags parameter in
802         :meth:`Database.add_message` to implicitly call this.
803
804         :returns: a :class:`STATUS`. In short, you want to see
805             notmuch.STATUS.SUCCESS here. See there for details."""
806         if not self._msg:
807             raise NotInitializedError()
808         return Message._tags_to_maildir_flags(self._msg)
809
810     def __repr__(self):
811         """Represent a Message() object by str()"""
812         return self.__str__()
813
814     def __unicode__(self):
815         format = "%s (%s) (%s)"
816         return format % (self.get_header('from'),
817                          self.get_tags(),
818                          date.fromtimestamp(self.get_date()),
819                         )
820
821     def get_message_parts(self):
822         """Output like notmuch show"""
823         fp = open(self.get_filename())
824         email_msg = email.message_from_file(fp)
825         fp.close()
826
827         out = []
828         for msg in email_msg.walk():
829             if not msg.is_multipart():
830                 out.append(msg)
831         return out
832
833     def get_part(self, num):
834         """Returns the nth message body part"""
835         parts = self.get_message_parts()
836         if (num <= 0 or num > len(parts)):
837             return ""
838         else:
839             out_part = parts[(num - 1)]
840             return out_part.get_payload(decode=True)
841
842     def format_message_internal(self):
843         """Create an internal representation of the message parts,
844         which can easily be output to json, text, or another output
845         format. The argument match tells whether this matched a
846         query."""
847         output = {}
848         output["id"] = self.get_message_id()
849         output["match"] = self.is_match()
850         output["filename"] = self.get_filename()
851         output["tags"] = list(self.get_tags())
852
853         headers = {}
854         for h in ["Subject", "From", "To", "Cc", "Bcc", "Date"]:
855             headers[h] = self.get_header(h)
856         output["headers"] = headers
857
858         body = []
859         parts = self.get_message_parts()
860         for i in xrange(len(parts)):
861             msg = parts[i]
862             part_dict = {}
863             part_dict["id"] = i + 1
864             # We'll be using this is a lot, so let's just get it once.
865             cont_type = msg.get_content_type()
866             part_dict["content-type"] = cont_type
867             # NOTE:
868             # Now we emulate the current behaviour, where it ignores
869             # the html if there's a text representation.
870             #
871             # This is being worked on, but it will be easier to fix
872             # here in the future than to end up with another
873             # incompatible solution.
874             disposition = msg["Content-Disposition"]
875             if disposition and disposition.lower().startswith("attachment"):
876                 part_dict["filename"] = msg.get_filename()
877             else:
878                 if cont_type.lower() == "text/plain":
879                     part_dict["content"] = msg.get_payload()
880                 elif (cont_type.lower() == "text/html" and
881                       i == 0):
882                     part_dict["content"] = msg.get_payload()
883             body.append(part_dict)
884
885         output["body"] = body
886
887         return output
888
889     def format_message_as_json(self, indent=0):
890         """Outputs the message as json. This is essentially the same
891         as python's dict format, but we run it through, just so we
892         don't have to worry about the details."""
893         return json.dumps(self.format_message_internal())
894
895     def format_message_as_text(self, indent=0):
896         """Outputs it in the old-fashioned notmuch text form. Will be
897         easy to change to a new format when the format changes."""
898
899         format = self.format_message_internal()
900         output = "\fmessage{ id:%s depth:%d match:%d filename:%s" \
901                  % (format['id'], indent, format['match'], format['filename'])
902         output += "\n\fheader{"
903
904         #Todo: this date is supposed to be prettified, as in the index.
905         output += "\n%s (%s) (" % (format["headers"]["From"],
906                                    format["headers"]["Date"])
907         output += ", ".join(format["tags"])
908         output += ")"
909
910         output += "\nSubject: %s" % format["headers"]["Subject"]
911         output += "\nFrom: %s" % format["headers"]["From"]
912         output += "\nTo: %s" % format["headers"]["To"]
913         if format["headers"]["Cc"]:
914             output += "\nCc: %s" % format["headers"]["Cc"]
915         if format["headers"]["Bcc"]:
916             output += "\nBcc: %s" % format["headers"]["Bcc"]
917         output += "\nDate: %s" % format["headers"]["Date"]
918         output += "\n\fheader}"
919
920         output += "\n\fbody{"
921
922         parts = format["body"]
923         parts.sort(key=lambda x: x['id'])
924         for p in parts:
925             if not "filename" in p:
926                 output += "\n\fpart{ "
927                 output += "ID: %d, Content-type: %s\n" % (p["id"],
928                                                           p["content-type"])
929                 if "content" in p:
930                     output += "\n%s\n" % p["content"]
931                 else:
932                     output += "Non-text part: %s\n" % p["content-type"]
933                     output += "\n\fpart}"
934             else:
935                 output += "\n\fattachment{ "
936                 output += "ID: %d, Content-type:%s\n" % (p["id"],
937                                                          p["content-type"])
938                 output += "Attachment: %s\n" % p["filename"]
939                 output += "\n\fattachment}\n"
940
941         output += "\n\fbody}\n"
942         output += "\n\fmessage}"
943
944         return output
945
946     def __hash__(self):
947         """Implement hash(), so we can use Message() sets"""
948         file = self.get_filename()
949         if not file:
950             return None
951         return hash(file)
952
953     def __cmp__(self, other):
954         """Implement cmp(), so we can compare Message()s
955
956         2 messages are considered equal if they point to the same
957         Message-Id and if they point to the same file names. If 2
958         Messages derive from different queries where some files have
959         been added or removed, the same messages would not be considered
960         equal (as they do not point to the same set of files
961         any more)."""
962         res = cmp(self.get_message_id(), other.get_message_id())
963         if res:
964             res = cmp(list(self.get_filenames()), list(other.get_filenames()))
965         return res
966
967     _destroy = nmlib.notmuch_message_destroy
968     _destroy.argtypes = [NotmuchMessageP]
969     _destroy.restype = None
970
971     def __del__(self):
972         """Close and free the notmuch Message"""
973         if self._msg is not None:
974             self._destroy(self._msg)