8fd9eacef0088c84da98e1b15eb34f71dba50a91
[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_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
27 import sys
28 import email
29 import types
30 try:
31     import simplejson as json
32 except ImportError:
33     import json
34 #------------------------------------------------------------------------------
35 class Messages(object):
36     """Represents a list of notmuch messages
37
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 library
41     only provides a one-time iterator (it cannot reset the iterator to
42     the start). Thus iterating over the function will "exhaust" the list
43     of messages, and a subsequent iteration attempt will raise a
44     :exc:`NotmuchError` STATUS.NOT_INITIALIZED. Also note, that any
45     function that uses iteration will also exhaust the messages.If you
46     need to re-iterate over a list of messages you will need to retrieve
47     a new :class:`Messages` object or cache your :class:`Message`s in a
48     list via::
49
50        msglist = list(msgs)
51
52     You can store and reuse the single Message objects as often as you
53     want as long as you keep the parent Messages object around. (Recall
54     that due to hierarchical memory allocation, all derived Message
55     objects will be invalid when we delete the parent Messages() object,
56     even if it was already "exhausted".) So this works::
57
58       db   = Database()
59       msgs = Query(db,'').search_messages() #get a Messages() object
60       msglist = list(msgs)
61
62       # msgs is "exhausted" now and even len(msgs) will raise an exception.
63       # However it will be kept around until all retrieved Message() objects are
64       # also deleted. If you did e.g. an explicit del(msgs) here, the 
65       # following lines would fail.
66       
67       # You can reiterate over *msglist* however as often as you want. 
68       # It is simply a list with Message objects.
69
70       print (msglist[0].get_filename())
71       print (msglist[1].get_filename())
72       print (msglist[0].get_message_id())
73     """
74
75     #notmuch_messages_get
76     _get = nmlib.notmuch_messages_get
77     _get.restype = c_void_p
78
79     _collect_tags = nmlib.notmuch_messages_collect_tags
80     _collect_tags.restype = c_void_p
81
82     def __init__(self, msgs_p, parent=None):
83         """
84         :param msgs_p:  A pointer to an underlying *notmuch_messages_t*
85              structure. These are not publically exposed, so a user
86              will almost never instantiate a :class:`Messages` object
87              herself. They are usually handed back as a result,
88              e.g. in :meth:`Query.search_messages`.  *msgs_p* must be
89              valid, we will raise an :exc:`NotmuchError`
90              (STATUS.NULL_POINTER) if it is `None`.
91         :type msgs_p: :class:`ctypes.c_void_p`
92         :param parent: The parent object
93              (ie :class:`Query`) these tags are derived from. It saves
94              a reference to it, so we can automatically delete the db
95              object once all derived objects are dead.
96         :TODO: Make the iterator work more than once and cache the tags in 
97                the Python object.(?)
98         """
99         if msgs_p is None:
100             NotmuchError(STATUS.NULL_POINTER)
101
102         self._msgs = msgs_p
103         #store parent, so we keep them alive as long as self  is alive
104         self._parent = parent
105
106     def collect_tags(self):
107         """Return the unique :class:`Tags` in the contained messages
108
109         :returns: :class:`Tags`
110         :exceptions: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if not inited
111
112         .. note:: :meth:`collect_tags` will iterate over the messages and
113           therefore will not allow further iterations.
114         """
115         if self._msgs is None:
116             raise NotmuchError(STATUS.NOT_INITIALIZED)
117
118         # collect all tags (returns NULL on error)
119         tags_p = Messages._collect_tags (self._msgs)
120         #reset _msgs as we iterated over it and can do so only once
121         self._msgs = None
122
123         if tags_p == None:
124             raise NotmuchError(STATUS.NULL_POINTER)
125         return Tags(tags_p, self)
126
127     def __iter__(self):
128         """ Make Messages an iterator """
129         return self
130
131     def next(self):
132         if self._msgs is None:
133             raise NotmuchError(STATUS.NOT_INITIALIZED)
134
135         if not nmlib.notmuch_messages_valid(self._msgs):
136             self._msgs = None
137             raise StopIteration
138
139         msg = Message(Messages._get (self._msgs), self)
140         nmlib.notmuch_messages_move_to_next(self._msgs)
141         return msg
142
143     def __nonzero__(self):
144         """
145         :return: True if there is at least one more thread in the
146             Iterator, False if not."""
147         return self._msgs is not None and \
148             nmlib.notmuch_messages_valid(self._msgs) > 0
149
150     def __del__(self):
151         """Close and free the notmuch Messages"""
152         if self._msgs is not None:
153             nmlib.notmuch_messages_destroy (self._msgs)
154
155     def print_messages(self, format, indent=0, entire_thread=False):
156         """Outputs messages as needed for 'notmuch show' to sys.stdout
157
158         :param format: A string of either 'text' or 'json'.
159         :param indent: A number indicating the reply depth of these messages.
160         :param entire_thread: A bool, indicating whether we want to output 
161                        whole threads or only the matching messages.
162         """
163         if format.lower() == "text":
164             set_start = ""
165             set_end = ""
166             set_sep = ""
167         elif format.lower() == "json":
168             set_start = "["
169             set_end = "]"
170             set_sep = ", "
171         else:
172             raise Exception
173
174         first_set = True
175
176         sys.stdout.write(set_start)
177
178         # iterate through all toplevel messages in this thread
179         for msg in self:
180             # if not msg:
181             #     break 
182             if not first_set:
183                 sys.stdout.write(set_sep)
184             first_set = False
185
186             sys.stdout.write(set_start)
187             match = msg.is_match()
188             next_indent = indent
189
190             if (match or entire_thread):
191                 if format.lower() == "text":
192                     sys.stdout.write(msg.format_message_as_text(indent))
193                 elif format.lower() == "json":
194                     sys.stdout.write(msg.format_message_as_json(indent))
195                 else:
196                     raise NotmuchError
197                 next_indent = indent + 1
198
199             # get replies and print them also out (if there are any)
200             replies = msg.get_replies()
201             if not replies is None:
202                 sys.stdout.write(set_sep)
203                 replies.print_messages(format, next_indent, entire_thread)
204
205             sys.stdout.write(set_end)
206         sys.stdout.write(set_end)
207
208 #------------------------------------------------------------------------------
209 class Message(object):
210     """Represents a single Email message
211
212     Technically, this wraps the underlying *notmuch_message_t* structure.
213     """
214
215     """notmuch_message_get_filename (notmuch_message_t *message)"""
216     _get_filename = nmlib.notmuch_message_get_filename
217     _get_filename.restype = c_char_p 
218
219     """return all filenames for a message"""
220     _get_filenames = nmlib.notmuch_message_get_filenames
221     _get_filenames.restype = c_void_p
222
223     """notmuch_message_get_flag"""
224     _get_flag = nmlib.notmuch_message_get_flag
225     _get_flag.restype = c_uint
226
227     """notmuch_message_get_message_id (notmuch_message_t *message)"""
228     _get_message_id = nmlib.notmuch_message_get_message_id
229     _get_message_id.restype = c_char_p 
230
231     """notmuch_message_get_thread_id"""
232     _get_thread_id = nmlib.notmuch_message_get_thread_id
233     _get_thread_id.restype = c_char_p
234
235     """notmuch_message_get_replies"""
236     _get_replies = nmlib.notmuch_message_get_replies
237     _get_replies.restype = c_void_p
238
239     """notmuch_message_get_tags (notmuch_message_t *message)"""
240     _get_tags = nmlib.notmuch_message_get_tags
241     _get_tags.restype = c_void_p
242
243     _get_date = nmlib.notmuch_message_get_date
244     _get_date.restype = c_long
245
246     _get_header = nmlib.notmuch_message_get_header
247     _get_header.restype = c_char_p
248
249     #Constants: Flags that can be set/get with set_flag
250     FLAG = Enum(['MATCH'])
251
252     def __init__(self, msg_p, parent=None):
253         """
254         :param msg_p: A pointer to an internal notmuch_message_t
255             Structure.  If it is `None`, we will raise an :exc:`NotmuchError`
256             STATUS.NULL_POINTER.
257         :param parent: A 'parent' object is passed which this message is
258               derived from. We save a reference to it, so we can
259               automatically delete the parent object once all derived
260               objects are dead.
261         """
262         if msg_p is None:
263             NotmuchError(STATUS.NULL_POINTER)
264         self._msg = msg_p
265         #keep reference to parent, so we keep it alive
266         self._parent = parent
267
268
269     def get_message_id(self):
270         """Returns the message ID
271         
272         :returns: String with a message ID
273         :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message 
274                     is not initialized.
275         """
276         if self._msg is None:
277             raise NotmuchError(STATUS.NOT_INITIALIZED)
278         return Message._get_message_id(self._msg)
279
280     def get_thread_id(self):
281         """Returns the thread ID
282
283         The returned string belongs to 'message' will only be valid for as 
284         long as the message is valid.
285
286         This function will not return None since Notmuch ensures that every
287         message belongs to a single thread.
288
289         :returns: String with a thread ID
290         :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message 
291                     is not initialized.
292         """
293         if self._msg is None:
294             raise NotmuchError(STATUS.NOT_INITIALIZED)
295
296         return Message._get_thread_id (self._msg);
297
298     def get_replies(self):
299         """Gets all direct replies to this message as :class:`Messages` iterator
300
301         .. note:: This call only makes sense if 'message' was
302           ultimately obtained from a :class:`Thread` object, (such as
303           by coming directly from the result of calling
304           :meth:`Thread.get_toplevel_messages` or by any number of
305           subsequent calls to :meth:`get_replies`). If this message was
306           obtained through some non-thread means, (such as by a call
307           to :meth:`Query.search_messages`), then this function will
308           return `None`.
309
310         :returns: :class:`Messages` or `None` if there are no replies to 
311             this message.
312         :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message 
313                     is not initialized.
314         """
315         if self._msg is None:
316             raise NotmuchError(STATUS.NOT_INITIALIZED)
317
318         msgs_p = Message._get_replies(self._msg);
319
320         if msgs_p is None:
321             return None
322
323         return Messages(msgs_p,self)
324
325     def get_date(self):
326         """Returns time_t of the message date
327
328         For the original textual representation of the Date header from the
329         message call notmuch_message_get_header() with a header value of
330         "date".
331
332         :returns: A time_t timestamp.
333         :rtype: c_unit64
334         :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message 
335                     is not initialized.
336         """
337         if self._msg is None:
338             raise NotmuchError(STATUS.NOT_INITIALIZED)
339         return Message._get_date(self._msg)
340
341     def get_header(self, header):
342         """Returns a message header
343         
344         This returns any message header that is stored in the notmuch database.
345         This is only a selected subset of headers, which is currently:
346
347           TODO: add stored headers
348
349         :param header: The name of the header to be retrieved.
350                        It is not case-sensitive (TODO: confirm).
351         :type header: str
352         :returns: The header value as string
353         :exception: :exc:`NotmuchError`
354
355                     * STATUS.NOT_INITIALIZED if the message 
356                       is not initialized.
357                     * STATUS.NULL_POINTER, if no header was found
358         """
359         if self._msg is None:
360             raise NotmuchError(STATUS.NOT_INITIALIZED)
361
362         #Returns NULL if any error occurs.
363         header = Message._get_header (self._msg, header)
364         if header == None:
365             raise NotmuchError(STATUS.NULL_POINTER)
366         return header
367
368     def get_filename(self):
369         """Returns the file path of the message file
370
371         :returns: Absolute file path & name of the message file
372         :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message 
373               is not initialized.
374         """
375         if self._msg is None:
376             raise NotmuchError(STATUS.NOT_INITIALIZED)
377         return Message._get_filename(self._msg)
378
379     def get_filenames(self):
380         """Get all filenames for the email corresponding to 'message'
381
382         Returns a Filenames() generator with all absolute filepaths for
383         messages recorded to have the same Message-ID. These files must
384         not necessarily have identical content."""
385         if self._msg is None:
386             raise NotmuchError(STATUS.NOT_INITIALIZED)
387         
388         files_p = Message._get_filenames(self._msg)
389
390         return Filenames(files_p, self).as_generator()
391
392     def get_flag(self, flag):
393         """Checks whether a specific flag is set for this message
394
395         The method :meth:`Query.search_threads` sets
396         *Message.FLAG.MATCH* for those messages that match the
397         query. This method allows us to get the value of this flag.
398
399         :param flag: One of the :attr:`Message.FLAG` values (currently only 
400                      *Message.FLAG.MATCH*
401         :returns: An unsigned int (0/1), indicating whether the flag is set.
402         :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message 
403               is not initialized.
404         """
405         if self._msg is None:
406             raise NotmuchError(STATUS.NOT_INITIALIZED)
407         return Message._get_flag(self._msg, flag)
408
409     def set_flag(self, flag, value):
410         """Sets/Unsets a specific flag for this message
411
412         :param flag: One of the :attr:`Message.FLAG` values (currently only 
413                      *Message.FLAG.MATCH*
414         :param value: A bool indicating whether to set or unset the flag.
415
416         :returns: Nothing
417         :exception: :exc:`NotmuchError` STATUS.NOT_INITIALIZED if the message 
418               is not initialized.
419         """
420         if self._msg is None:
421             raise NotmuchError(STATUS.NOT_INITIALIZED)
422         nmlib.notmuch_message_set_flag(self._msg, flag, value)
423
424     def get_tags(self):
425         """Returns the message tags
426
427         :returns: A :class:`Tags` iterator.
428         :exception: :exc:`NotmuchError`
429
430                       * STATUS.NOT_INITIALIZED if the message 
431                         is not initialized.
432                       * STATUS.NULL_POINTER, on error
433         """
434         if self._msg is None:
435             raise NotmuchError(STATUS.NOT_INITIALIZED)
436
437         tags_p = Message._get_tags(self._msg)
438         if tags_p == None:
439             raise NotmuchError(STATUS.NULL_POINTER)
440         return Tags(tags_p, self)
441
442     def add_tag(self, tag):
443         """Adds a tag to the given message
444
445         Adds a tag to the current message. The maximal tag length is defined in
446         the notmuch library and is currently 200 bytes.
447
448         :param tag: String with a 'tag' to be added.
449         :returns: STATUS.SUCCESS if the tag was successfully added.
450                   Raises an exception otherwise.
451         :exception: :exc:`NotmuchError`. They have the following meaning:
452
453                   STATUS.NULL_POINTER
454                     The 'tag' argument is NULL
455                   STATUS.TAG_TOO_LONG
456                     The length of 'tag' is too long 
457                     (exceeds Message.NOTMUCH_TAG_MAX)
458                   STATUS.READ_ONLY_DATABASE
459                     Database was opened in read-only mode so message cannot be 
460                     modified.
461                   STATUS.NOT_INITIALIZED
462                      The message has not been initialized.
463        """
464         if self._msg is None:
465             raise NotmuchError(STATUS.NOT_INITIALIZED)
466
467         status = nmlib.notmuch_message_add_tag (self._msg, tag)
468
469         if STATUS.SUCCESS == status:
470             # return on success
471             return status
472
473         raise NotmuchError(status)
474
475     def remove_tag(self, tag):
476         """Removes a tag from the given message
477
478         If the message has no such tag, this is a non-operation and
479         will report success anyway.
480
481         :param tag: String with a 'tag' to be removed.
482         :returns: STATUS.SUCCESS if the tag was successfully removed or if 
483                   the message had no such tag.
484                   Raises an exception otherwise.
485         :exception: :exc:`NotmuchError`. They have the following meaning:
486
487                    STATUS.NULL_POINTER
488                      The 'tag' argument is NULL
489                    STATUS.TAG_TOO_LONG
490                      The length of 'tag' is too long
491                      (exceeds NOTMUCH_TAG_MAX)
492                    STATUS.READ_ONLY_DATABASE
493                      Database was opened in read-only mode so message cannot 
494                      be modified.
495                    STATUS.NOT_INITIALIZED
496                      The message has not been initialized.
497         """
498         if self._msg is None:
499             raise NotmuchError(STATUS.NOT_INITIALIZED)
500
501         status = nmlib.notmuch_message_remove_tag(self._msg, tag)
502
503         if STATUS.SUCCESS == status:
504             # return on success
505             return status
506
507         raise NotmuchError(status)
508
509     def remove_all_tags(self):
510         """Removes all tags from the given message.
511
512         See :meth:`freeze` for an example showing how to safely
513         replace tag values.
514
515         :returns: STATUS.SUCCESS if the tags were successfully removed.
516                   Raises an exception otherwise.
517         :exception: :exc:`NotmuchError`. They have the following meaning:
518
519                    STATUS.READ_ONLY_DATABASE
520                      Database was opened in read-only mode so message cannot 
521                      be modified.
522                    STATUS.NOT_INITIALIZED
523                      The message has not been initialized.
524         """
525         if self._msg is None:
526             raise NotmuchError(STATUS.NOT_INITIALIZED)
527  
528         status = nmlib.notmuch_message_remove_all_tags(self._msg)
529
530         if STATUS.SUCCESS == status:
531             # return on success
532             return status
533
534         raise NotmuchError(status)
535
536     def freeze(self):
537         """Freezes the current state of 'message' within the database
538
539         This means that changes to the message state, (via :meth:`add_tag`, 
540         :meth:`remove_tag`, and :meth:`remove_all_tags`), will not be 
541         committed to the database until the message is :meth:`thaw`ed.
542
543         Multiple calls to freeze/thaw are valid and these calls will
544         "stack". That is there must be as many calls to thaw as to freeze
545         before a message is actually thawed.
546
547         The ability to do freeze/thaw allows for safe transactions to
548         change tag values. For example, explicitly setting a message to
549         have a given set of tags might look like this::
550
551           msg.freeze()
552           msg.remove_all_tags()
553           for tag in new_tags:
554               msg.add_tag(tag)
555           msg.thaw()
556
557         With freeze/thaw used like this, the message in the database is
558         guaranteed to have either the full set of original tag values, or
559         the full set of new tag values, but nothing in between.
560
561         Imagine the example above without freeze/thaw and the operation
562         somehow getting interrupted. This could result in the message being
563         left with no tags if the interruption happened after
564         :meth:`remove_all_tags` but before :meth:`add_tag`.
565
566         :returns: STATUS.SUCCESS if the message was successfully frozen.
567                   Raises an exception otherwise.
568         :exception: :exc:`NotmuchError`. They have the following meaning:
569
570                    STATUS.READ_ONLY_DATABASE
571                      Database was opened in read-only mode so message cannot 
572                      be modified.
573                    STATUS.NOT_INITIALIZED
574                      The message has not been initialized.
575         """
576         if self._msg is None:
577             raise NotmuchError(STATUS.NOT_INITIALIZED)
578  
579         status = nmlib.notmuch_message_freeze(self._msg)
580
581         if STATUS.SUCCESS == status:
582             # return on success
583             return status
584
585         raise NotmuchError(status)
586
587     def thaw(self):
588         """Thaws the current 'message'
589
590         Thaw the current 'message', synchronizing any changes that may have 
591         occurred while 'message' was frozen into the notmuch database.
592
593         See :meth:`freeze` for an example of how to use this
594         function to safely provide tag changes.
595
596         Multiple calls to freeze/thaw are valid and these calls with
597         "stack". That is there must be as many calls to thaw as to freeze
598         before a message is actually thawed.
599
600         :returns: STATUS.SUCCESS if the message was successfully frozen.
601                   Raises an exception otherwise.
602         :exception: :exc:`NotmuchError`. They have the following meaning:
603
604                    STATUS.UNBALANCED_FREEZE_THAW
605                      An attempt was made to thaw an unfrozen message. 
606                      That is, there have been an unbalanced number of calls 
607                      to :meth:`freeze` and :meth:`thaw`.
608                    STATUS.NOT_INITIALIZED
609                      The message has not been initialized.
610         """
611         if self._msg is None:
612             raise NotmuchError(STATUS.NOT_INITIALIZED)
613  
614         status = nmlib.notmuch_message_thaw(self._msg)
615
616         if STATUS.SUCCESS == status:
617             # return on success
618             return status
619
620         raise NotmuchError(status)
621
622
623     def is_match(self):
624         """(Not implemented)"""
625         return self.get_flag(Message.FLAG.MATCH)
626
627     def __str__(self):
628         """A message() is represented by a 1-line summary"""
629         msg = {}
630         msg['from'] = self.get_header('from')
631         msg['tags'] = str(self.get_tags())
632         msg['date'] = date.fromtimestamp(self.get_date())
633         replies = self.get_replies()
634         msg['replies'] = len(replies) if replies is not None else -1
635         return "%(from)s (%(date)s) (%(tags)s) (%(replies)d) replies" % (msg)
636
637
638     def get_message_parts(self):
639         """Output like notmuch show"""
640         fp = open(self.get_filename())
641         email_msg = email.message_from_file(fp)
642         fp.close()
643
644         out = []
645         for msg in email_msg.walk():
646             if not msg.is_multipart():
647                 out.append(msg)
648         return out
649
650     def get_part(self, num):
651         """Returns the nth message body part"""
652         parts = self.get_message_parts()
653         if (num <= 0 or num > len(parts)):
654             return ""
655         else:
656             out_part = parts[(num - 1)]
657             return out_part.get_payload(decode=True)
658
659     def format_message_internal(self):
660         """Create an internal representation of the message parts,
661         which can easily be output to json, text, or another output
662         format. The argument match tells whether this matched a
663         query."""
664         output = {}
665         output["id"] = self.get_message_id()
666         output["match"] = self.is_match()
667         output["filename"] = self.get_filename()
668         output["tags"] = list(self.get_tags())
669
670         headers = {}
671         for h in ["Subject", "From", "To", "Cc", "Bcc", "Date"]:
672             headers[h] = self.get_header(h)
673         output["headers"] = headers
674
675         body = []
676         parts = self.get_message_parts()
677         for i in xrange(len(parts)):
678             msg = parts[i]
679             part_dict = {}
680             part_dict["id"] = i + 1
681             # We'll be using this is a lot, so let's just get it once.
682             cont_type = msg.get_content_type()
683             part_dict["content-type"] = cont_type
684             # NOTE:
685             # Now we emulate the current behaviour, where it ignores
686             # the html if there's a text representation. 
687             #
688             # This is being worked on, but it will be easier to fix
689             # here in the future than to end up with another
690             # incompatible solution.
691             disposition = msg["Content-Disposition"]
692             if disposition and disposition.lower().startswith("attachment"):
693                 part_dict["filename"] = msg.get_filename()
694             else:
695                 if cont_type.lower() == "text/plain":
696                     part_dict["content"] = msg.get_payload()
697                 elif (cont_type.lower() == "text/html" and 
698                       i == 0):
699                     part_dict["content"] = msg.get_payload()
700             body.append(part_dict)
701
702         output["body"] = body
703
704         return output
705
706     def format_message_as_json(self, indent=0):
707         """Outputs the message as json. This is essentially the same
708         as python's dict format, but we run it through, just so we
709         don't have to worry about the details."""
710         return json.dumps(self.format_message_internal())
711
712     def format_message_as_text(self, indent=0):
713         """Outputs it in the old-fashioned notmuch text form. Will be
714         easy to change to a new format when the format changes."""
715
716         format = self.format_message_internal()
717         output = "\fmessage{ id:%s depth:%d match:%d filename:%s" \
718                  % (format['id'], indent, format['match'], format['filename'])
719         output += "\n\fheader{"
720
721         #Todo: this date is supposed to be prettified, as in the index.
722         output += "\n%s (%s) (" % (format["headers"]["From"],
723                                    format["headers"]["Date"])
724         output += ", ".join(format["tags"])
725         output += ")"
726
727         output += "\nSubject: %s" % format["headers"]["Subject"]
728         output += "\nFrom: %s" % format["headers"]["From"]
729         output += "\nTo: %s" % format["headers"]["To"]
730         if format["headers"]["Cc"]:
731             output += "\nCc: %s" % format["headers"]["Cc"]
732         if format["headers"]["Bcc"]:
733             output += "\nBcc: %s" % format["headers"]["Bcc"]
734         output += "\nDate: %s" % format["headers"]["Date"]
735         output += "\n\fheader}"
736
737         output += "\n\fbody{"
738
739         parts = format["body"]
740         parts.sort(key=lambda x: x['id'])
741         for p in parts:
742             if not p.has_key("filename"):
743                 output += "\n\fpart{ "
744                 output += "ID: %d, Content-type: %s\n" % (p["id"], 
745                                                          p["content-type"])
746                 if p.has_key("content"):
747                     output += "\n%s\n" % p["content"]
748                 else:
749                     output += "Non-text part: %s\n" % p["content-type"]
750                     output += "\n\fpart}"                    
751             else:
752                 output += "\n\fattachment{ "
753                 output += "ID: %d, Content-type:%s\n" % (p["id"], 
754                                                          p["content-type"])
755                 output += "Attachment: %s\n" % p["filename"]
756                 output += "\n\fattachment}\n"
757
758         output += "\n\fbody}\n"
759         output += "\n\fmessage}"
760
761         return output
762
763     def __del__(self):
764         """Close and free the notmuch Message"""
765         if self._msg is not None:
766             nmlib.notmuch_message_destroy (self._msg)