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