""" This file is part of alot. Alot is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Alot is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with notmuch. If not, see . Copyright (C) 2011 Patrick Totzke """ from notmuch import Database, NotmuchError from datetime import datetime import email from collections import deque import os from settings import config import helper DB_ENC = 'utf8' class DatabaseError(Exception): pass class DatabaseROError(DatabaseError): pass class DatabaseLockedError(DatabaseError): pass class DBManager: """ keeps track of your index parameters, can create notmuch.Query objects from its Database on demand and implements a bunch of database specific functions. """ 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: boolean """ self.ro = ro self.path = path self.writequeue = deque([]) def flush(self): """ tries to flush all queued write commands to the index. :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: try: mode = Database.MODE.READ_WRITE db = Database(path=self.path, mode=mode) except NotmuchError: raise DatabaseLockedError() while self.writequeue: cmd, querystring, tags = self.writequeue.popleft() 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=True) if cmd == 'set': msg.remove_all_tags() for tag in tags: msg.add_tag(tag.encode(DB_ENC), sync_maildir_flags=True) elif cmd == 'untag': for tag in tags: msg.remove_tag(tag.encode(DB_ENC), sync_maildir_flags=True) msg.thaw() def tag(self, querystring, tags, remove_rest=False): """ add tags to all matching messages. 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 remove_rest: remove tags from matching messages before tagging :type remove_rest: boolean :exception: :exc:`NotmuchError` """ if self.ro: raise DatabaseROError() if remove_rest: self.writequeue.append(('set', querystring, tags)) else: self.writequeue.append(('tag', querystring, tags)) def untag(self, querystring, tags): """ add tags to all matching messages. 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 :exception: :exc:`NotmuchError` """ if self.ro: raise DatabaseROError() self.writequeue.append(('untag', 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.""" threads = self.query(querystring).search_threads() return [thread.get_thread_id() for thread in threads] def get_thread(self, tid): """returns the thread with given id as alot.db.Thread object""" query = self.query('thread:' + tid) #TODO raise exceptions here in 01 return Thread(self, query.search_threads().next()) def get_all_tags(self): """returns all tags as list of strings""" db = Database(path=self.path) return list(db.get_all_tags()) def query(self, querystring): """creates notmuch.Query objects on demand :param querystring: The query string to use for the lookup :type query: str. :returns: notmuch.Query -- the query object. """ mode = Database.MODE.READ_ONLY db = Database(path=self.path, mode=mode) return db.create_query(querystring) class Thread: def __init__(self, dbman, thread): """ :param dbman: db manager that is used for further lookups :type dbman: alot.db.DBManager :param msg: the wrapped thread :type msg: notmuch.database.Thread """ self._dbman = dbman self._id = thread.get_thread_id() self._total_messages = thread.get_total_messages() self._authors = str(thread.get_authors()).decode(DB_ENC) self._subject = str(thread.get_subject()).decode(DB_ENC) self._oldest_date = datetime.fromtimestamp(thread.get_oldest_date()) self._newest_date = datetime.fromtimestamp(thread.get_newest_date()) self._tags = set(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): """returns tags attached to this thread as list of strings""" return list(self._tags) def add_tags(self, tags): """adds tags to all messages in this thread :param tags: tags to add :type tags: list of str """ newtags = set(tags).difference(self._tags) if newtags: self._dbman.tag('thread:' + self._id, newtags) self._tags = self._tags.union(newtags) def remove_tags(self, tags): """remove tags from all messages in this thread :param tags: tags to remove :type tags: list of str """ rmtags = set(tags).intersection(self._tags) if rmtags: self._dbman.untag('thread:' + self._id, tags) self._tags = self._tags.difference(rmtags) def set_tags(self, tags): """set tags of all messages in this thread. This removes all tags and attaches the given ones in one step. :param tags: tags to add :type tags: list of str """ self._dbman.tag('thread:' + self._id, tags, remove_rest=True) self._tags = set(tags) def get_authors(self): # TODO: make this return a list of strings """returns all authors in this thread""" return self._authors def get_subject(self): """returns this threads subject""" return self._subject def get_toplevel_messages(self): """returns all toplevel messages as list of :class:`Message`""" if not self._messages: self.get_messages() return self._toplevel_messages def get_messages(self): """returns all messages in this thread :returns: dict mapping all contained :class:`Message`s to a list of their respective children. """ if not self._messages: 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 :param msg: the parent message, must be contained in thread :type msg: alot.sb.Message """ 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 datetime""" return self._newest_date def get_oldest_date(self): """returns date header of oldest message in this thread as datetime""" return self._oldest_date def get_total_messages(self): """returns number of contained messages""" return self._total_messages class Message: 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 :type thread: alot.db.thread """ self._dbman = dbman self._id = msg.get_message_id() self._thread_id = msg.get_thread_id() self._thread = thread self._datetime = datetime.fromtimestamp(msg.get_date()) self._filename = msg.get_filename() # TODO: change api to return unicode self._from = msg.get_header('From').decode(DB_ENC) 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 #tags = ','.join(self.get_tags()) return "%s (%s)" % (aname, self.get_datestring()) def __hash__(self): """Implement hash(), so we can use Message() sets""" return hash(self._id) def __cmp__(self, other): """Implement cmp(), so we can compare Message()s""" res = cmp(self.get_message_id(), other.get_message_id()) return res def get_email(self): """returns email.Message representing this message""" if not self._email: f_mail = open(self.get_filename()) self._email = email.message_from_file(f_mail) f_mail.close() return self._email def get_date(self): """returns date as datetime obj""" return self._datetime def get_filename(self): """returns absolute path of messages location""" return self._filename def get_message_id(self): """returns messages id (a string)""" return self._id def get_thread_id(self): """returns id of messages thread (a string)""" return self._thread_id def get_message_parts(self): """returns a list of all body parts of this message""" 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""" return list(self._tags) def get_thread(self): """returns the thread this msg belongs to as alot.db.Thread object""" if not self._thread: self._thread = self._dbman.get_thread(self._thread_id) return self._thread def get_replies(self): """returns a list of replies to this msg""" t = self.get_thread() return t.get_replies_to(self) def get_datestring(self, pretty=True): """returns formated datestring in sup-style, eg: 'Yest.3pm'""" return helper.pretty_datetime(self._datetime) def get_author(self): """returns realname and address pair of this messages author""" return email.Utils.parseaddr(self._from) def add_tags(self, tags): """adds tags to message :param tags: tags to add :type tags: list of str """ self._dbman.tag('id:' + self._id, tags) self._tags = self._tags.union(tags) def remove_tags(self, tags): """remove tags from message :param tags: tags to remove :type tags: list of str """ self._dbman.untag('id:' + self._id, tags) self._tags = self._tags.difference(tags) def get_attachments(self): if not self._attachments: self._attachments = [] for part in self.get_message_parts(): if part.get_content_maintype() != 'text': self._attachments.append(Attachment(part)) return self._attachments class Attachment: """represents a single mail attachment""" def __init__(self, emailpart): """ :param emailpart: a non-multipart email that is the attachment :type emailpart: email.message.Message """ self.part = emailpart def __str__(self): return '%s:%s (%s)' % (self.get_content_type(), self.get_filename(), self.get_size()) def get_filename(self): """return the filename, extracted from content-disposition header""" return self.part.get_filename() def get_content_type(self): """mime type of the attachment""" return self.part.get_content_type() def get_size(self): """returns attachments size as human-readable string""" size_in_kbyte = len(self.part.get_payload()) / 1024 if size_in_kbyte > 1024: return "%.1fM" % (size_in_kbyte / 1024.0) else: return "%dK" % size_in_kbyte def save(self, path): """save the attachment to disk. Uses self.get_filename in case path is a directory""" if os.path.isdir(path): path = os.path.join(path, self.get_filename()) FILE = open(path, "w") FILE.write(self.part.get_payload(decode=True)) FILE.close()