# coding: utf-8 """ :mod:`imaputils` --- Extra IMAPv4 utilities ------------------------------------------- """ import imaplib, ssl, email import re from functools import wraps from imapclient.response_parser import parse_fetch_response from modoboa.lib import parameters from modoboa.lib.connections import * from modoboa.lib.webutils import static_url from exceptions import ImapError #imaplib.Debug = 4 class capability(object): """ Simple decorator to check if the server presents the required capability. If not, a fallback method is called instead. :param name: the capability name (upper case) :param fallback_method: a method's name """ def __init__(self, name, fallback_method): self.name = name self.fallback_method = fallback_method def __call__(self, method): @wraps(method) def wrapped_func(cls, *args, **kwargs): if self.name in cls.m.capabilities: return method(cls, *args, **kwargs) return getattr(cls, self.fallback_method)(*args, **kwargs) return wrapped_func class IMAPconnector(object): __metaclass__ = ConnectionsManager list_base_pattern = r'\((?P.*?)\) "(?P.*)" "(?P[^"]*)"' list_response_pattern = re.compile(list_base_pattern) listextended_response_pattern = \ re.compile(list_base_pattern + r'\s*(?P.*)') unseen_pattern = re.compile(r'[^\(]+\(UNSEEN (\d+)\)') def __init__(self, user=None, password=None): self.criterions = [] self.address = parameters.get_admin("IMAP_SERVER") self.port = int(parameters.get_admin("IMAP_PORT")) status, msg = self.login(user, password) if not status: raise Exception(msg) def _cmd(self, name, *args): """IMAP command wrapper To simplify errors handling, this wrapper calls the appropriate method (``uid`` or FIXME) and then check the return code. If an error has occured, an ``ImapError`` exception is raised. For specific commands commands (FETCH, ...), the result is parsed using the IMAPclient module before being returned. :param name: the command's name :return: the command's result """ if name in ['FETCH', 'SORT', 'STORE', 'COPY']: try: typ, data = self.m.uid(name, *args) except imaplib.IMAP4.error, e: raise ImapError(e) if typ == "NO": raise ImapError(data) if name == 'FETCH': return parse_fetch_response(data) return data try: typ, data = self.m._simple_command(name, *args) except imaplib.IMAP4.error, e: raise ImapError(e) if typ == "NO": raise ImapError(data) if not name in self.m.untagged_responses: return None return self.m.untagged_responses.pop(name) def __find_content_in_bodystruct(self, bodystruct, mtype, stype, prefix=""): """Retrieve the number (index) of a specific part This part number will generally be used inside a FETCH request to specify a ``BODY.PEEK`` section. :param bodystruct: a BODYSTRUCTURE list :param mtype: the MIME main type (like text) :param stype: the MIME sub type (like plain) :param prefix: the prefix that will be added to the current part number :return: a tuple (index (None on error), encoding (string), size (int)) """ if type(bodystruct[0]) in [list, tuple]: cpt = 1 for part in bodystruct[0]: nprefix = "%s" % cpt if prefix == "" else "%s.%d" % (prefix, cpt) index, encoding, size = \ self.__find_content_in_bodystruct(bodystruct[0], mtype, stype, nprefix) if index: return (index, encoding, size) cpt += 1 if bodystruct[0] == mtype and bodystruct[1] == stype: return ("1" if not len(prefix) else prefix, bodystruct[5], int(bodystruct[6])) return (None, None, 0) def refresh(self, user, password): """Check if current connection needs a refresh Is it really secure? """ if self.m is not None: try: self.m.select() return except imaplib.IMAP4.error, error: print error print self.login(user, password) def login(self, user, passwd): try: secured = parameters.get_admin("IMAP_SECURED") if secured == "yes": self.m = imaplib.IMAP4_SSL(self.address, self.port) else: self.m = imaplib.IMAP4(self.address, self.port) except (socket.error, imaplib.IMAP4.error, ssl.SSLError), error: return False, _("Connection to IMAP server failed, check your configuration") try: self.m.login(user, passwd) except (imaplib.IMAP4.error, ssl.SSLError), error: return False, _("Authentication failed, check your configuration") return True, None def logout(self): try: self.m.select() except imaplib.IMAP4.error: pass self.m.logout() del self.m self.m = None def messages_count(self, **kwargs): """An enhanced version of messages_count With IMAP, to know how many messages a mailbox contains, we have to make a request to the server. To avoid requests multiplications, we sort messages in the same time. This will be usefull for other methods. :param order: sorting order :param folder: mailbox to scan """ if "order" in kwargs.keys() and kwargs["order"]: sign = kwargs["order"][:1] criterion = kwargs["order"][1:].upper() if sign == '-': criterion = "REVERSE %s" % criterion else: criterion = "REVERSE DATE" folder = kwargs.has_key("folder") and kwargs["folder"] or None self.select_mailbox(folder, False) data = self._cmd("SORT", "(%s)" % criterion, "UTF-8", "(NOT DELETED)", *self.criterions) self.messages = data[0].split() self.getquota(folder) return len(self.messages) def select_mailbox(self, name, readonly=True): """Issue a SELECT/EXAMINE command to the server The given name is first 'imap-utf7' encoded. :param name: mailbox's name :param readonly: """ name = name.encode("imap4-utf-7") if readonly: self._cmd("EXAMINE", name) else: self._cmd("SELECT", name) def unseen_messages(self, mailbox): """Return the number of unseen messages :param mailbox: the mailbox's name :return: an integer """ # self.select_mailbox(mailbox) # data = self._cmd("SEARCH", "UTF-8", "(NOT DELETED UNSEEN)") # return len(data[0].split()) data = self._cmd("STATUS", mailbox.encode("imap4-utf-7"), "(UNSEEN)") m = self.unseen_pattern.match(data[0]) if m is None: return 0 return int(m.group(1)) def _encodefolder(self, folder): if not folder: return "INBOX" return folder.encode("imap4-utf-7") def _parse_folder_name(self, descr, prefix, delimiter, parts): if not len(parts): return False path = "%s%s%s" % (prefix, delimiter, parts[0]) sdescr = None for d in descr: if d["path"] == path: sdescr = d break if sdescr is None: sdescr = {"name" : parts[0], "path" : path, "sub" : []} descr += [sdescr] if self._parse_folder_name(sdescr["sub"], path, delimiter, parts[1:]): sdescr["class"] = "subfolders" return True def _listfolders_simple(self, topmailbox='INBOX', md_mailboxes=[]): (status, data) = self.m.list() result = [] for mb in data: flags, delimiter, name = self.list_response_pattern.match(mb).groups() name = name.strip('"').decode("imap4-utf-7") if re.search("\%s" % delimiter, name): parts = name.split(".") if not descr.has_key("path"): descr["path"] = parts[0] descr["sub"] = [] if self._parse_folder_name(descr["sub"], parts[0], delimiter, parts[1:]): descr["class"] = "subfolders" continue present = False descr = {"name" : name} for mdf in md_mailboxes: if mdf["name"] == name: present = True break if not present: result += [descr] from operator import itemgetter return sorted(result, key=itemgetter("name")) @capability('LIST-EXTENDED', '_listfolders_simple') def _listfolders(self, topmailbox='', md_mailboxes=[]): pattern = ("%s.%%" % topmailbox.encode("imap4-utf-7")) if len(topmailbox) else "%" resp = self._cmd("LSUB", "", pattern, "RETURN", "(CHILDREN)") result = [] for mb in resp: flags, delimiter, name, childinfo = \ self.listextended_response_pattern.match(mb).groups() flags = flags.split(' ') name = name.decode("imap4-utf-7") present = False for mdm in md_mailboxes: if mdm["name"] == name: present = True break if present: continue descr = dict(name=name) if r'\HasChildren' in flags: descr["path"] = name descr["sub"] = [] descr["class"] = "subfolders" result += [descr] from operator import itemgetter return sorted(result, key=itemgetter("name")) def getfolders(self, user, topmailbox='', unseen_messages=True): if len(topmailbox): md_mailboxes = [] else: md_mailboxes = [{"name" : "INBOX", "class" : "inbox"}, {"name" : parameters.get_user(user, "DRAFTS_FOLDER"), "class" : "drafts"}, {"name" : 'Junk'}, {"name" : parameters.get_user(user, "SENT_FOLDER")}, {"name" : parameters.get_user(user, "TRASH_FOLDER"), "class" : "trash"}] md_mailboxes += self._listfolders(topmailbox, md_mailboxes) if unseen_messages: for fd in md_mailboxes: key = fd.has_key("path") and "path" or "name" count = self.unseen_messages(fd[key]) if count == 0: continue fd["unseen"] = count return md_mailboxes def _add_flag(self, mbox, msgset, flag): """Add flag(s) to a messages set :param mbox: the mailbox containing the messages :param msgset: messages set (uid) :param flag: the flag to add """ self.select_mailbox(mbox, False) self._cmd("STORE", msgset, "+FLAGS", flag) def mark_messages_unread(self, mbox, msgset): """Mark a set of messages as unread :param mbox: the mailbox containing the messages :param msgset: messages set (uid) """ self.select_mailbox(mbox, False) self._cmd("STORE", msgset, "-FLAGS", r'(\Seen)') def mark_messages_read(self, mbox, msgset): """Mark a set of messages as unread :param mbox: the mailbox containing the messages :param msgset: messages set (uid) """ self._add_flag(mbox, msgset, r'(\Seen)') def msgforwarded(self, folder, imapid): self._add_flag(folder, imapid, '($Forwarded)') def msg_answered(self, folder, imapid): self._add_flag(folder, imapid, r'(\Answered)') def move(self, msgset, oldmailbox, newmailbox): """Move messages between mailboxes """ self.select_mailbox(oldmailbox, False) self._cmd("COPY", msgset, newmailbox.encode("imap4-utf-7")) self._cmd("STORE", msgset, "+FLAGS", r'(\Deleted)') def push_mail(self, folder, msg): now = imaplib.Time2Internaldate(time.time()) self.m.append(self._encodefolder(folder), r'(\Seen)', now, str(msg)) def empty(self, mbox): self.select_mailbox(mbox, False) resp = self._cmd("SEARCH", "ALL") print resp # for num in data[0].split(): # self.m.store(num, "+FLAGS", r'(\Deleted)') # self.m.expunge() def compact(self, mbox): """Compact a specific mailbox Issue an EXPUNGE command for the specified mailbox. :param mbox: the mailbox's name """ self.select_mailbox(mbox, False) self._cmd("EXPUNGE") def create_folder(self, name, parent=None): if parent is not None: name = "%s.%s" % (parent, name) typ, data = self.m.create(self._encodefolder(name)) if typ == "NO": raise WebmailError(data[0]) return True def rename_folder(self, oldname, newname): typ, data = self.m.rename(self._encodefolder(oldname), self._encodefolder(newname)) if typ == "NO": raise WebmailError(data[0], ajax=True) return True def delete_folder(self, name): typ, data = self.m.delete(self._encodefolder(name)) if typ == "NO": raise WebmailError(data[0]) return True def getquota(self, folder): if not "QUOTA" in self.m.capabilities: self.quota_limit = self.quota_actual = None return status, data = self.m.getquotaroot(self._encodefolder(folder)) if status == "OK": quotadef = data[1][0] m = re.match("[^\s]+ \(STORAGE (\d+) (\d+)\)", quotadef) if not m: print "Problem while parsing quota def" return self.quota_limit = int(m.group(2)) self.quota_actual = int(m.group(1)) def fetchpart(self, uid, folder, part): self.m.select(self._encodefolder(folder), True) typ, data = self.m.fetch(uid, "(BODY[%(p)s.MIME] BODY[%(p)s])" \ % {"p" : part}) if typ != "OK": return None msg = email.message_from_string(data[0][1] + data[1][1]) return msg def fetch(self, start, stop=None, mbox=None, **kwargs): """Retrieve information about messages from the server Issue a FETCH command to retrieve information about one or more messages (such as headers) from the server. :param start: index of the first message :param stop: index of the last message (optionnal) :param mbox: the mailbox that contains the messages """ self.select_mailbox(mbox, False) if start and stop: submessages = self.messages[start - 1:stop] range = ",".join(submessages) else: submessages = [start] range = start query = '(FLAGS BODY.PEEK[HEADER.FIELDS (DATE FROM TO CC SUBJECT)])' data = self._cmd("FETCH", range, query) result = [] for uid in submessages: msg = email.message_from_string(data[int(uid)]['BODY[HEADER.FIELDS (DATE FROM TO CC SUBJECT)]']) msg['imapid'] = uid if not r'\Seen' in data[int(uid)]['FLAGS']: msg['class'] = 'unseen' if r'\Answered' in data[int(uid)]['FLAGS']: msg['img_flags'] = static_url('pics/answered.png') result += [msg] return result def fetchmail(self, mbox, mailid, fmt="plain"): """Retrieve information about a specific message Issue a FETCH command to retrieve a message's content from the server. In order to not overload the server, we first retrieve the BODYSTRUCTURE of the message. Then, according to the result and to the user's preferences, we retrieve the appropriate content (plain, html, etc.). :param mbox: the mailbox containing the message :param mailid: the message's unique id :param fmt: the desired content's format """ self.select_mailbox(mbox, False) data = self._cmd("FETCH", mailid, "(BODYSTRUCTURE BODY.PEEK[HEADER.FIELDS (DATE FROM TO CC SUBJECT)])") content = data[int(mailid)]['BODY[HEADER.FIELDS (DATE FROM TO CC SUBJECT)]'] fallback_fmt = "html" if fmt == "plain" else "plain" (pnum, encoding, size) = (None, None, 0) for f in [fmt, fallback_fmt]: pnum, encoding, size = \ self.__find_content_in_bodystruct(data[int(mailid)]['BODYSTRUCTURE'], "text", f) if pnum is not None and size: break if pnum is None: return None data = self._cmd("FETCH", mailid, "(BODY.PEEK[%s])" % pnum) data = data[int(mailid)]['BODY[%s]' % pnum] if encoding == "base64": import base64 content += base64.b64decode(data) elif encoding == "quoted-printable": import quopri content += quopri.decodestring(data) else: content += data return email.message_from_string(content) def get_imapconnector(request): imapc = IMAPconnector(user=request.user.username, password=request.session["password"]) return imapc