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