diff options
Diffstat (limited to 'alot/db')
-rw-r--r-- | alot/db/__init__.py | 348 | ||||
-rw-r--r-- | alot/db/message.py | 664 | ||||
-rw-r--r-- | alot/db/thread.py | 265 |
3 files changed, 1277 insertions, 0 deletions
diff --git a/alot/db/__init__.py b/alot/db/__init__.py new file mode 100644 index 00000000..b2796324 --- /dev/null +++ b/alot/db/__init__.py @@ -0,0 +1,348 @@ +from notmuch import Database, NotmuchError, XapianError +import notmuch +import multiprocessing +import logging + +from collections import deque + +from message import Message +from alot.settings import settings +from thread import Thread + +DB_ENC = 'utf-8' + + +class DatabaseError(Exception): + pass + + +class DatabaseROError(DatabaseError): + """cannot write to read-only database""" + pass + + +class DatabaseLockedError(DatabaseError): + """cannot write to locked index""" + pass + + +class NonexistantObjectError(DatabaseError): + """requested thread or message does not exist in the index""" + pass + + +class FillPipeProcess(multiprocessing.Process): + def __init__(self, it, pipe, fun=(lambda x: x)): + multiprocessing.Process.__init__(self) + self.it = it + self.pipe = pipe[1] + self.fun = fun + + def run(self): + for a in self.it: + self.pipe.send(self.fun(a)) + self.pipe.close() + + +class DBManager(object): + """ + Keeps track of your index parameters, maintains a write-queue and + lets you look up threads and messages directly to the persistent wrapper + classes. + """ + _sort_orders = { + 'oldest_first': notmuch.database.Query.SORT.OLDEST_FIRST, + 'newest_first': notmuch.database.Query.SORT.NEWEST_FIRST, + 'unsorted': notmuch.database.Query.SORT.UNSORTED, + 'message_id': notmuch.database.Query.SORT.MESSAGE_ID, + } + """constants representing sort orders""" + + def __init__(self, path=None, ro=False): + """ + :param path: absolute path to the notmuch index + :type path: str + :param ro: open the index in read-only mode + :type ro: bool + """ + self.ro = ro + self.path = path + self.writequeue = deque([]) + self.processes = [] + + def flush(self): + """ + write out all queued write-commands in order, each one in a separate + :meth:`atomic <notmuch.Database.begin_atomic>` transaction. + + If this fails the current action is rolled back, stays in the write + queue and an exception is raised. + You are responsible to retry flushing at a later time if you want to + ensure that the cached changes are applied to the database. + + :exception: :exc:`DatabaseROError` if db is opened in read-only mode + :exception: :exc:`DatabaseLockedError` if db is locked + """ + if self.ro: + raise DatabaseROError() + if self.writequeue: + # aquire a writeable db handler + try: + mode = Database.MODE.READ_WRITE + db = Database(path=self.path, mode=mode) + except NotmuchError: + raise DatabaseLockedError() + + # read notmuch's config regarding imap flag synchronization + sync = settings.get_notmuch_setting('maildir', 'synchronize_flags') + + # go through writequeue entries + while self.writequeue: + current_item = self.writequeue.popleft() + logging.debug('write-out item: %s' % str(current_item)) + + # watch out for notmuch errors to re-insert current_item + # to the queue on errors + try: + # the first two coordinants are cnmdname and post-callback + cmd, afterwards = current_item[:2] + + # make this a transaction + db.begin_atomic() + + if cmd == 'add': + path, tags = current_item[2:] + msg, status = db.add_message(path, + sync_maildir_flags=sync) + msg.freeze() + for tag in tags: + msg.add_tag(tag.encode(DB_ENC), + sync_maildir_flags=sync) + msg.thaw() + + elif cmd == 'remove': + path = current_item[2] + db.remove_message(path) + + else: # tag/set/untag + querystring, tags = current_item[2:] + query = db.create_query(querystring) + for msg in query.search_messages(): + msg.freeze() + if cmd == 'tag': + for tag in tags: + msg.add_tag(tag.encode(DB_ENC), + sync_maildir_flags=sync) + if cmd == 'set': + msg.remove_all_tags() + for tag in tags: + msg.add_tag(tag.encode(DB_ENC), + sync_maildir_flags=sync) + elif cmd == 'untag': + for tag in tags: + msg.remove_tag(tag.encode(DB_ENC), + sync_maildir_flags=sync) + msg.thaw() + + # end transaction and reinsert queue item on error + if db.end_atomic() != notmuch.STATUS.SUCCESS: + raise DatabaseError('fail-status from end_atomic') + + # call post-callback + if callable(afterwards): + afterwards() + + # re-insert item to the queue upon Xapian/NotmuchErrors + except (XapianError, NotmuchError) as e: + self.writequeue.appendleft(current_item) + raise DatabaseError(unicode(e)) + + def kill_search_processes(self): + """ + terminate all search processes that originate from + this managers :meth:`get_threads`. + """ + for p in self.processes: + p.terminate() + self.processes = [] + + def tag(self, querystring, tags, afterwards=None, remove_rest=False): + """ + add tags to messages matching `querystring`. + This appends a tag operation to the write queue and raises + :exc:`DatabaseROError` if in read only mode. + + :param querystring: notmuch search string + :type querystring: str + :param tags: a list of tags to be added + :type tags: list of str + :param afterwards: callback that gets called after successful + application of this tagging operation + :type afterwards: callable + :param remove_rest: remove tags from matching messages before tagging + :type remove_rest: bool + :exception: :exc:`DatabaseROError` + + .. note:: + You need to call :meth:`DBManager.flush` to actually write out. + """ + if self.ro: + raise DatabaseROError() + if remove_rest: + self.writequeue.append(('set', afterwards, querystring, tags)) + else: + self.writequeue.append(('tag', afterwards, querystring, tags)) + + def untag(self, querystring, tags, afterwards=None): + """ + removes tags from messages that match `querystring`. + This appends an untag operation to the write queue and raises + :exc:`DatabaseROError` if in read only mode. + + :param querystring: notmuch search string + :type querystring: str + :param tags: a list of tags to be added + :type tags: list of str + :param afterwards: callback that gets called after successful + application of this tagging operation + :type afterwards: callable + :exception: :exc:`DatabaseROError` + + .. note:: + You need to call :meth:`DBManager.flush` to actually write out. + """ + if self.ro: + raise DatabaseROError() + self.writequeue.append(('untag', afterwards, querystring, tags)) + + def count_messages(self, querystring): + """returns number of messages that match `querystring`""" + return self.query(querystring).count_messages() + + def search_thread_ids(self, querystring): + """ + returns the ids of all threads that match the `querystring` + This copies! all integer thread ids into an new list. + + :returns: list of str + """ + + return self.query_threaded(querystring) + + def _get_notmuch_thread(self, tid): + """returns :class:`notmuch.database.Thread` with given id""" + query = self.query('thread:' + tid) + try: + return query.search_threads().next() + except StopIteration: + errmsg = 'no thread with id %s exists!' % tid + raise NonexistantObjectError(errmsg) + + def get_thread(self, tid): + """returns :class:`Thread` with given thread id (str)""" + return Thread(self, self._get_notmuch_thread(tid)) + + def _get_notmuch_message(self, mid): + """returns :class:`notmuch.database.Message` with given id""" + mode = Database.MODE.READ_ONLY + db = Database(path=self.path, mode=mode) + try: + return db.find_message(mid) + except: + errmsg = 'no message with id %s exists!' % mid + raise NonexistantObjectError(errmsg) + + def get_message(self, mid): + """returns :class:`Message` with given message id (str)""" + return Message(self, self._get_notmuch_message(mid)) + + def get_all_tags(self): + """ + returns all tagsstrings used in the database + :rtype: list of str + """ + db = Database(path=self.path) + return [t for t in db.get_all_tags()] + + def async(self, cbl, fun): + """ + return a pair (pipe, process) so that the process writes + `fun(a)` to the pipe for each element `a` in the iterable returned + by the callable `cbl`. + + :param cbl: a function returning something iterable + :type cbl: callable + :param fun: an unary translation function + :type fun: callable + :rtype: (:class:`multiprocessing.Pipe`, + :class:`multiprocessing.Process`) + """ + pipe = multiprocessing.Pipe(False) + receiver, sender = pipe + process = FillPipeProcess(cbl(), pipe, fun) + process.start() + self.processes.append(process) + # closing the sending end in this (receiving) process guarantees + # that here the apropriate EOFError is raised upon .recv in the walker + sender.close() + return receiver, process + + def get_threads(self, querystring, sort='newest_first'): + """ + asynchronously look up thread ids matching `querystring`. + + :param querystring: The query string to use for the lookup + :type querystring: str. + :param sort: Sort order. one of ['oldest_first', 'newest_first', + 'message_id', 'unsorted'] + :type query: str + :returns: a pipe together with the process that asynchronously + writes to it. + :rtype: (:class:`multiprocessing.Pipe`, + :class:`multiprocessing.Process`) + """ + assert sort in self._sort_orders.keys() + q = self.query(querystring) + q.set_sort(self._sort_orders[sort]) + return self.async(q.search_threads, (lambda a: a.get_thread_id())) + + def query(self, querystring): + """ + creates :class:`notmuch.Query` objects on demand + + :param querystring: The query string to use for the lookup + :type query: str. + :returns: :class:`notmuch.Query` -- the query object. + """ + mode = Database.MODE.READ_ONLY + db = Database(path=self.path, mode=mode) + return db.create_query(querystring) + + def add_message(self, path, tags=[], afterwards=None): + """ + Adds a file to the notmuch index. + + :param path: path to the file + :type path: str + :param tags: tagstrings to add + :type tags: list of str + :param afterwards: callback to trigger after adding + :type afterwards: callable or None + """ + if self.ro: + raise DatabaseROError() + self.writequeue.append(('add', afterwards, path, tags)) + + def remove_message(self, message, afterwards=None): + """ + Remove a message from the notmuch index + + :param message: message to remove + :type message: :class:`Message` + :param afterwards: callback to trigger after removing + :type afterwards: callable or None + """ + if self.ro: + raise DatabaseROError() + path = message.get_filename() + self.writequeue.append(('remove', afterwards, path)) diff --git a/alot/db/message.py b/alot/db/message.py new file mode 100644 index 00000000..aa9b8a1e --- /dev/null +++ b/alot/db/message.py @@ -0,0 +1,664 @@ +import os +import email +import tempfile +import re +import shlex +from datetime import datetime +from email.header import Header +import email.charset as charset +charset.add_charset('utf-8', charset.QP, charset.QP, 'utf-8') +from email.iterators import typed_subpart_iterator +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from notmuch import NullPointerError + +from alot import __version__ +import logging +import alot.helper as helper +from alot.settings import settings +from alot.helper import string_sanitize +from alot.helper import string_decode + + +class Message(object): + """ + a persistent notmuch message object. + It it uses a :class:`~alot.db.DBManager` for cached manipulation + and lazy lookups. + """ + def __init__(self, dbman, msg, thread=None): + """ + :param dbman: db manager that is used for further lookups + :type dbman: alot.db.DBManager + :param msg: the wrapped message + :type msg: notmuch.database.Message + :param thread: this messages thread (will be looked up later if `None`) + :type thread: :class:`~alot.db.Thread` or `None` + """ + self._dbman = dbman + self._id = msg.get_message_id() + self._thread_id = msg.get_thread_id() + self._thread = thread + casts_date = lambda: datetime.fromtimestamp(msg.get_date()) + self._datetime = helper.safely_get(casts_date, + ValueError, None) + self._filename = msg.get_filename() + self._from = helper.safely_get(lambda: msg.get_header('From'), + NullPointerError) + self._email = None # will be read upon first use + self._attachments = None # will be read upon first use + self._tags = set(msg.get_tags()) + + def __str__(self): + """prettyprint the message""" + aname, aaddress = self.get_author() + if not aname: + aname = aaddress + return "%s (%s)" % (aname, self.get_datestring()) + + def __hash__(self): + """needed for sets of Messages""" + return hash(self._id) + + def __cmp__(self, other): + """needed for Message comparison""" + res = cmp(self.get_message_id(), other.get_message_id()) + return res + + def get_email(self): + """returns :class:`email.Message` for this message""" + path = self.get_filename() + warning = "Subject: Caution!\n"\ + "Message file is no longer accessible:\n%s" % path + if not self._email: + try: + f_mail = open(path) + self._email = email.message_from_file(f_mail) + f_mail.close() + except IOError: + self._email = email.message_from_string(warning) + return self._email + + def get_date(self): + """returns Date header value as :class:`~datetime.datetime`""" + return self._datetime + + def get_filename(self): + """returns absolute path of message files location""" + return self._filename + + def get_message_id(self): + """returns messages id (str)""" + return self._id + + def get_thread_id(self): + """returns id (str) of the thread this message belongs to""" + return self._thread_id + + def get_message_parts(self): + """returns a list of all body parts of this message""" + # TODO really needed? email iterators can do this + out = [] + for msg in self.get_email().walk(): + if not msg.is_multipart(): + out.append(msg) + return out + + def get_tags(self): + """returns tags attached to this message as list of strings""" + l = list(self._tags) + l.sort() + return l + + def get_thread(self): + """returns the :class:`~alot.db.Thread` this msg belongs to""" + if not self._thread: + self._thread = self._dbman.get_thread(self._thread_id) + return self._thread + + def has_replies(self): + """returns true if this message has at least one reply""" + return (len(self.get_replies()) > 0) + + def get_replies(self): + """returns replies to this message as list of :class:`Message`""" + t = self.get_thread() + return t.get_replies_to(self) + + def get_datestring(self): + """ + returns reformated datestring for this messages. + + It uses the format spacified by `timestamp_format` in + the general section of the config. + """ + if self._datetime == None: + return None + formatstring = settings.get('timestamp_format') + if formatstring == None: + res = helper.pretty_datetime(self._datetime) + else: + res = self._datetime.strftime(formatstring) + return res + + def get_author(self): + """ + returns realname and address of this messages author + + :rtype: (str,str) + """ + return email.Utils.parseaddr(self._from) + + def get_headers_string(self, headers): + """ + returns subset of this messages headers as human-readable format: + all header values are decoded, the resulting string has + one line "KEY: VALUE" for each requested header present in the mail. + + :param headers: headers to extract + :type headers: list of str + """ + return extract_headers(self.get_mail(), headers) + + def add_tags(self, tags, afterwards=None, remove_rest=False): + """ + adds tags to message + + .. note:: + + This only adds the requested operation to this objects + :class:`DBManager's <alot.db.DBManager>` write queue. + You need to call :meth:`~alot.db.DBManager.flush` to write out. + + :param tags: a list of tags to be added + :type tags: list of str + :param afterwards: callback that gets called after successful + application of this tagging operation + :type afterwards: callable + :param remove_rest: remove all other tags + :type remove_rest: bool + """ + def myafterwards(): + if remove_rest: + self._tags = set(tags) + else: + self._tags = self._tags.union(tags) + if callable(afterwards): + afterwards() + + self._dbman.tag('id:' + self._id, tags, afterwards=myafterwards, + remove_rest=remove_rest) + self._tags = self._tags.union(tags) + + def remove_tags(self, tags, afterwards=None): + """remove tags from message + + .. note:: + + This only adds the requested operation to this objects + :class:`DBManager's <alot.db.DBManager>` write queue. + You need to call :meth:`~alot.db.DBManager.flush` to actually out. + + :param tags: a list of tags to be added + :type tags: list of str + :param afterwards: callback that gets called after successful + application of this tagging operation + :type afterwards: callable + """ + def myafterwards(): + self._tags = self._tags.difference(tags) + if callable(afterwards): + afterwards() + + self._dbman.untag('id:' + self._id, tags, myafterwards) + + def get_attachments(self): + """ + returns messages attachments + + Derived from the leaves of the email mime tree + that and are not part of :rfc:`2015` syntax for encrypted/signed mails + and either have :mailheader:`Content-Disposition` `attachment` + or have :mailheader:`Content-Disposition` `inline` but specify + a filename (as parameter to `Content-Disposition`). + + :rtype: list of :class:`Attachment` + """ + if not self._attachments: + self._attachments = [] + for part in self.get_message_parts(): + cd = part.get('Content-Disposition', '') + filename = part.get_filename() + ct = part.get_content_type() + # replace underspecified mime description by a better guess + if ct in ['octet/stream', 'application/octet-stream']: + content = part.get_payload(decode=True) + ct = helper.guess_mimetype(content) + + if cd.startswith('attachment'): + if ct not in ['application/pgp-encrypted', + 'application/pgp-signature']: + self._attachments.append(Attachment(part)) + elif cd.startswith('inline'): + if filename != None and ct != 'application/pgp': + self._attachments.append(Attachment(part)) + return self._attachments + + def accumulate_body(self): + """ + returns bodystring extracted from this mail + """ + #TODO: don't hardcode which part is considered body but allow toggle + # commands and a config default setting + + return extract_body(self.get_email()) + + def get_text_content(self): + return extract_body(self.get_email(), types=['text/plain']) + + def matches(self, querystring): + """tests if this messages is in the resultset for `querystring`""" + searchfor = querystring + ' AND id:' + self._id + return self._dbman.count_messages(searchfor) > 0 + + +def extract_headers(mail, headers=None): + headertext = u'' + if headers == None: + headers = mail.keys() + for key in headers: + value = u'' + if key in mail: + value = decode_header(mail.get(key, '')) + headertext += '%s: %s\n' % (key, value) + return headertext + + +def extract_body(mail, types=None): + """ + returns a body text string for given mail. + If types is `None`, 'text/*' is used: + In case mail has a 'text/html' part, it is prefered over + 'text/plain' parts. + + :param mail: the mail to use + :type mail: :class:`email.Message` + :param types: mime content types to use for body string + :type types: list of str + """ + html = list(typed_subpart_iterator(mail, 'text', 'html')) + + # if no specific types are given, we favor text/html over text/plain + drop_plaintext = False + if html and not types: + drop_plaintext = True + + body_parts = [] + for part in mail.walk(): + ctype = part.get_content_type() + + if types is not None: + if ctype not in types: + continue + cd = part.get('Content-Disposition', '') + if cd.startswith('attachment'): + continue + + enc = part.get_content_charset() or 'ascii' + raw_payload = part.get_payload(decode=True) + if part.get_content_maintype() == 'text': + raw_payload = string_decode(raw_payload, enc) + if ctype == 'text/plain' and not drop_plaintext: + body_parts.append(string_sanitize(raw_payload)) + else: + #get mime handler + handler = settings.get_mime_handler(ctype, key='view', + interactive=False) + if handler: + #open tempfile. Not all handlers accept stuff from stdin + tmpfile = tempfile.NamedTemporaryFile(delete=False, + suffix='.html') + #write payload to tmpfile + if part.get_content_maintype() == 'text': + tmpfile.write(raw_payload.encode('utf8')) + else: + tmpfile.write(raw_payload) + tmpfile.close() + #create and call external command + cmd = handler % tmpfile.name + cmdlist = shlex.split(cmd.encode('utf-8', errors='ignore')) + rendered_payload, errmsg, retval = helper.call_cmd(cmdlist) + #remove tempfile + os.unlink(tmpfile.name) + if rendered_payload: # handler had output + body_parts.append(string_sanitize(rendered_payload)) + elif part.get_content_maintype() == 'text': + body_parts.append(string_sanitize(raw_payload)) + # else drop + return '\n\n'.join(body_parts) + + +def decode_header(header, normalize=False): + """ + decode a header value to a unicode string + + values are usually a mixture of different substrings + encoded in quoted printable using diffetrent encodings. + This turns it into a single unicode string + + :param header: the header value + :type header: str + :param normalize: replace trailing spaces after newlines + :type normalize: bool + :rtype: unicode + """ + + # If the value isn't ascii as RFC2822 prescribes, + # we just return the unicode bytestring as is + value = string_decode(header) # convert to unicode + try: + value = value.encode('ascii') + except UnicodeEncodeError: + return value + + # otherwise we interpret RFC2822 encoding escape sequences + valuelist = email.header.decode_header(header) + decoded_list = [] + for v, enc in valuelist: + v = string_decode(v, enc) + decoded_list.append(string_sanitize(v)) + value = u' '.join(decoded_list) + if normalize: + value = re.sub(r'\n\s+', r' ', value) + return value + + +def encode_header(key, value): + """ + encodes a unicode string as a valid header value + + :param key: the header field this value will be stored in + :type key: str + :param value: the value to be encoded + :type value: unicode + """ + # handle list of "realname <email>" entries separately + if key.lower() in ['from', 'to', 'cc', 'bcc']: + rawentries = value.split(',') + encodedentries = [] + for entry in rawentries: + m = re.search('\s*(.*)\s+<(.*\@.*\.\w*)>\s*$', entry) + if m: # If a realname part is contained + name, address = m.groups() + # try to encode as ascii, if that fails, revert to utf-8 + # name must be a unicode string here + namepart = Header(name) + # append address part encoded as ascii + entry = '%s <%s>' % (namepart.encode(), address) + encodedentries.append(entry) + value = Header(', '.join(encodedentries)) + else: + value = Header(value) + return value + + +class Attachment(object): + """represents a mail attachment""" + + def __init__(self, emailpart): + """ + :param emailpart: a non-multipart email that is the attachment + :type emailpart: :class:`email.message.Message` + """ + self.part = emailpart + + def __str__(self): + desc = '%s:%s (%s)' % (self.get_content_type(), + self.get_filename(), + helper.humanize_size(self.get_size())) + return string_decode(desc) + + def get_filename(self): + """ + return name of attached file. + If the content-disposition header contains no file name, + this returns `None` + """ + extracted_name = decode_header(self.part.get_filename()) + if extracted_name: + return os.path.basename(extracted_name) + return None + + def get_content_type(self): + """mime type of the attachment part""" + ctype = self.part.get_content_type() + # replace underspecified mime description by a better guess + if ctype in ['octet/stream', 'application/octet-stream']: + ctype = helper.guess_mimetype(self.get_data()) + return ctype + + def get_size(self): + """returns attachments size in bytes""" + return len(self.part.get_payload()) + + def save(self, path): + """ + save the attachment to disk. Uses :meth:`get_filename` in case path + is a directory + """ + filename = self.get_filename() + path = os.path.expanduser(path) + if os.path.isdir(path): + if filename: + basename = os.path.basename(filename) + FILE = open(os.path.join(path, basename), "w") + else: + FILE = tempfile.NamedTemporaryFile(delete=False, dir=path) + else: + FILE = open(path, "w") # this throws IOErrors for invalid path + FILE.write(self.get_data()) + FILE.close() + return FILE.name + + def get_data(self): + """return data blob from wrapped file""" + return self.part.get_payload(decode=True) + + def get_mime_representation(self): + """returns mime part that constitutes this attachment""" + return self.part + + +class Envelope(object): + """a message that is not yet sent and still editable""" + def __init__(self, template=None, bodytext=u'', headers={}, attachments=[], + sign=False, encrypt=False): + """ + :param template: if not None, the envelope will be initialised by + :meth:`parsing <parse_template>` this string before + setting any other values given to this constructor. + :type template: str + :param bodytext: text used as body part + :type bodytext: str + :param headers: unencoded header values + :type headers: dict (str -> unicode) + :param attachments: file attachments to include + :type attachments: list of :class:`Attachment` + """ + assert isinstance(bodytext, unicode) + self.headers = {} + self.body = None + logging.debug('TEMPLATE: %s' % template) + if template: + self.parse_template(template) + logging.debug('PARSED TEMPLATE: %s' % template) + logging.debug('BODY: %s' % self.body) + if self.body == None: + self.body = bodytext + self.headers.update(headers) + self.attachments = list(attachments) + self.sign = sign + self.encrypt = encrypt + self.sent_time = None + self.modified_since_sent = False + + def __str__(self): + return "Envelope (%s)\n%s" % (self.headers, self.body) + + def __setitem__(self, name, val): + """setter for header values. this allows adding header like so: + + >>> envelope['Subject'] = u'sm\xf8rebr\xf8d' + """ + self.headers[name] = val + + if self.sent_time: + self.modified_since_sent = True + + def __getitem__(self, name): + """getter for header values. + :raises: KeyError if undefined + """ + return self.headers[name] + + def __delitem__(self, name): + del(self.headers[name]) + + if self.sent_time: + self.modified_since_sent = True + + def __contains__(self, name): + return self.headers.__contains__(name) + + def get(self, key, fallback=None): + """secure getter for header values that allows specifying a `fallback` + return string (defaults to None). This returns the first matching value + and doesn't raise KeyErrors""" + if key in self.headers: + value = self.headers[key][0] + else: + value = fallback + return value + + def get_all(self, key, fallback=[]): + """returns all header values for given key""" + if key in self.headers: + value = self.headers[key] + else: + value = fallback + return value + + def add(self, key, value): + """add header value""" + if key not in self.headers: + self.headers[key] = [] + self.headers[key].append(value) + + if self.sent_time: + self.modified_since_sent = True + + def attach(self, attachment, filename=None, ctype=None): + """ + attach a file + + :param attachment: File to attach, given as :class:`Attachment` object + or path to a file. + :type attachment: :class:`Attachment` or str + :param filename: filename to use in content-disposition. + Will be ignored if `path` matches multiple files + :param ctype: force content-type to be used for this attachment + :type ctype: str + """ + + if isinstance(attachment, Attachment): + self.attachments.append(attachment) + elif isinstance(attachment, basestring): + path = os.path.expanduser(attachment) + part = helper.mimewrap(path, filename, ctype) + self.attachments.append(Attachment(part)) + else: + raise TypeError('attach accepts an Attachment or str') + + if self.sent_time: + self.modified_since_sent = True + + def construct_mail(self): + """ + compiles the information contained in this envelope into a + :class:`email.Message`. + """ + # build body text part + textpart = MIMEText(self.body.encode('utf-8'), 'plain', 'utf-8') + + # wrap it in a multipart container if necessary + if self.attachments or self.sign or self.encrypt: + msg = MIMEMultipart() + msg.attach(textpart) + else: + msg = textpart + + headers = self.headers.copy() + # add Message-ID + if 'Message-ID' not in headers: + headers['Message-ID'] = [email.Utils.make_msgid()] + + if 'User-Agent' in headers: + uastring_format = headers['User-Agent'][0] + else: + uastring_format = settings.get('user_agent').strip() + uastring = uastring_format.format(version=__version__) + if uastring: + headers['User-Agent'] = [uastring] + + # copy headers from envelope to mail + for k, vlist in headers.items(): + for v in vlist: + msg[k] = encode_header(k, v) + + # add attachments + for a in self.attachments: + msg.attach(a.get_mime_representation()) + + return msg + + def parse_template(self, tmp, reset=False, only_body=False): + """parses a template or user edited string to fills this envelope. + + :param tmp: the string to parse. + :type tmp: str + :param reset: remove previous envelope content + :type reset: bool + """ + logging.debug('GoT: """\n%s\n"""' % tmp) + + if self.sent_time: + self.modified_since_sent = True + + if only_body: + self.body = tmp + else: + m = re.match('(?P<h>([a-zA-Z0-9_-]+:.+\n)*)\n?(?P<b>(\s*.*)*)', + tmp) + assert m + + d = m.groupdict() + headertext = d['h'] + self.body = d['b'] + + # remove existing content + if reset: + self.headers = {} + + # go through multiline, utf-8 encoded headers + # we decode the edited text ourselves here as + # email.message_from_file can't deal with raw utf8 header values + key = value = None + for line in headertext.splitlines(): + if re.match('[a-zA-Z0-9_-]+:', line): # new k/v pair + if key and value: # save old one from stack + self.add(key, value) # save + key, value = line.strip().split(':', 1) # parse new pair + elif key and value: # append new line without key prefix + value += line + if key and value: # save last one if present + self.add(key, value) diff --git a/alot/db/thread.py b/alot/db/thread.py new file mode 100644 index 00000000..a13eac0e --- /dev/null +++ b/alot/db/thread.py @@ -0,0 +1,265 @@ +from datetime import datetime + +from message import Message +from alot.settings import settings + + +class Thread(object): + """ + A wrapper around a notmuch mailthread (:class:`notmuch.database.Thread`) + that ensures persistence of the thread: It can be safely read multiple + times, its manipulation is done via a :class:`alot.db.DBManager` and it + can directly provide contained messages as :class:`~alot.message.Message`. + """ + + def __init__(self, dbman, thread): + """ + :param dbman: db manager that is used for further lookups + :type dbman: :class:`~alot.db.DBManager` + :param thread: the wrapped thread + :type thread: :class:`notmuch.database.Thread` + """ + self._dbman = dbman + self._id = thread.get_thread_id() + self.refresh(thread) + + def refresh(self, thread=None): + """refresh thread metadata from the index""" + if not thread: + thread = self._dbman._get_notmuch_thread(self._id) + + self._total_messages = thread.get_total_messages() + self._notmuch_authors_string = thread.get_authors() + self._subject = thread.get_subject() + self._authors = None + ts = thread.get_oldest_date() + + try: + self._oldest_date = datetime.fromtimestamp(ts) + except ValueError: # year is out of range + self._oldest_date = None + try: + timestamp = thread.get_newest_date() + self._newest_date = datetime.fromtimestamp(timestamp) + except ValueError: # year is out of range + self._newest_date = None + + self._tags = set([t for t in thread.get_tags()]) + self._messages = {} # this maps messages to its children + self._toplevel_messages = [] + + def __str__(self): + return "thread:%s: %s" % (self._id, self.get_subject()) + + def get_thread_id(self): + """returns id of this thread""" + return self._id + + def get_tags(self, intersection=False): + """ + returns tagsstrings attached to this thread + + :param intersection: return tags present in all contained messages + instead of in at least one (union) + :type intersection: bool + :rtype: set of str + """ + tags = set(list(self._tags)) + if intersection: + for m in self.get_messages().keys(): + tags = tags.intersection(set(m.get_tags())) + return tags + + def add_tags(self, tags, afterwards=None, remove_rest=False): + """ + add `tags` to all messages in this thread + + .. note:: + + This only adds the requested operation to this objects + :class:`DBManager's <~alot.db.DBManager>` write queue. + You need to call :meth:`DBManager.flush <~alot.db.DBManager.flush>` + to actually write out. + + :param tags: a list of tags to be added + :type tags: list of str + :param afterwards: callback that gets called after successful + application of this tagging operation + :type afterwards: callable + :param remove_rest: remove all other tags + :type remove_rest: bool + """ + def myafterwards(): + if remove_rest: + self._tags = set(tags) + else: + self._tags = self._tags.union(tags) + if callable(afterwards): + afterwards() + + self._dbman.tag('thread:' + self._id, tags, afterwards=myafterwards, + remove_rest=remove_rest) + + def remove_tags(self, tags, afterwards=None): + """ + remove `tags` (list of str) from all messages in this thread + + .. note:: + + This only adds the requested operation to this objects + :class:`DBManager's <alot.db.DBManager>` write queue. + You need to call :meth:`DBManager.flush <alot.db.DBManager.flush>` + to actually write out. + + :param tags: a list of tags to be added + :type tags: list of str + :param afterwards: callback that gets called after successful + application of this tagging operation + :type afterwards: callable + """ + rmtags = set(tags).intersection(self._tags) + if rmtags: + + def myafterwards(): + self._tags = self._tags.difference(tags) + if callable(afterwards): + afterwards() + self._dbman.untag('thread:' + self._id, tags, myafterwards) + self._tags = self._tags.difference(rmtags) + + def get_authors(self): + """ + returns a list of authors (name, addr) of the messages. + The authors are ordered by msg date and unique (by addr). + + :rtype: list of (str, str) + """ + if self._authors == None: + self._authors = [] + seen = {} + msgs = self.get_messages().keys() + msgs.sort(lambda a, b: cmp(a, b), lambda m: m.get_date()) + for m in msgs: + pair = m.get_author() + if not pair[1] in seen: + seen[pair[1]] = True + self._authors.append(pair) + return self._authors + + def get_authors_string(self, own_addrs=None, replace_own=None): + """ + returns a string of comma-separated authors + Depending on settings, it will substitute "me" for author name if + address is user's own. + + :param own_addrs: list of own email addresses to replace + :type own_addrs: list of str + :param replace_own: whether or not to actually do replacement + :type replace_own: bool + :rtype: str + """ + if replace_own == None: + replace_own = settings.get('thread_authors_replace_me') + if replace_own: + if own_addrs == None: + own_addrs = settings.get_addresses() + authorslist = [] + for aname, aaddress in self.get_authors(): + if aaddress in own_addrs: + aname = settings.get('thread_authors_me') + if not aname: + aname = aaddress + if not aname in authorslist: + authorslist.append(aname) + return ', '.join(authorslist) + else: + return self._notmuch_authors_string + + def get_subject(self): + """returns subject string""" + return self._subject + + def get_toplevel_messages(self): + """ + returns all toplevel messages contained in this thread. + This are all the messages without a parent message + (identified by 'in-reply-to' or 'references' header. + + :rtype: list of :class:`~alot.message.Message` + """ + if not self._messages: + self.get_messages() + return self._toplevel_messages + + def get_messages(self): + """ + returns all messages in this thread as dict mapping all contained + messages to their direct responses. + + :rtype: dict mapping :class:`~alot.message.Message` to a list of + :class:`~alot.message.Message`. + """ + if not self._messages: # if not already cached + query = self._dbman.query('thread:' + self._id) + thread = query.search_threads().next() + + def accumulate(acc, msg): + M = Message(self._dbman, msg, thread=self) + acc[M] = [] + r = msg.get_replies() + if r is not None: + for m in r: + acc[M].append(accumulate(acc, m)) + return M + + self._messages = {} + for m in thread.get_toplevel_messages(): + self._toplevel_messages.append(accumulate(self._messages, m)) + return self._messages + + def get_replies_to(self, msg): + """ + returns all replies to the given message contained in this thread. + + :param msg: parent message to look up + :type msg: :class:`~alot.message.Message` + :returns: list of :class:`~alot.message.Message` or `None` + """ + mid = msg.get_message_id() + msg_hash = self.get_messages() + for m in msg_hash.keys(): + if m.get_message_id() == mid: + return msg_hash[m] + return None + + def get_newest_date(self): + """ + returns date header of newest message in this thread as + :class:`~datetime.datetime` + """ + return self._newest_date + + def get_oldest_date(self): + """ + returns date header of oldest message in this thread as + :class:`~datetime.datetime` + """ + return self._oldest_date + + def get_total_messages(self): + """returns number of contained messages""" + return self._total_messages + + def matches(self, query): + """ + Check if this thread matches the given notmuch query. + + :param query: The query to check against + :type query: string + :returns: True if this thread matches the given query, False otherwise + :rtype: bool + """ + thread_query = 'thread:{tid} AND {subquery}'.format(tid=self._id, + subquery=query) + num_matches = self._dbman.count_messages(thread_query) + return num_matches > 0 |