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