import email import email.mime.application import email.mime.multipart import email.mime.text from email.header import decode_header, make_header import os.path import random import smtplib import string import imapclient import kode256 from bureau import Bureau, add_command, add_api def clean_header(header): """ Converts header string to unicode with HTML entities """ header = str(make_header(decode_header(header))) header = header.replace("<", "<") header = header.replace(">", ">") return header class Message(object): """ This is just a convenience class for holding email message data. It could be fancier some day but for now this seems fine. """ pass class MailRoom(Bureau): """ The Mail Room handles (electronic) post for the other Bureaus of the Screenless Office. """ name = "Mail Room" prefix = "PO" version = 0 def __init__(self): Bureau.__init__(self) # TODO: multiple accounts / folders if "user" in self.config: self.login = self.config["user"]["login"] self.password = self.config["user"]["password"] self.host = self.config["user"]["host"] self.spamfolder = self.config["user"]["spamfolder"] self.trashfolder = self.config["user"]["trashfolder"] self.imap_ssl = self.config["user"]["ssl"] self.imapserv = imapclient.IMAPClient(self.host, use_uid=True, ssl=self.imap_ssl) self.imapserv.login(self.login, self.password) self.imapserv.select_folder("INBOX") else: print("you need to configure an IMAP account!") print("add a user: section to PO.yml with:") print(" login: mylogin") print(" password: mypassword") print(" host: my.imap.server.address.com") print(" spamfolder: Junk") print(" trashfolder: Trash") print(" ssl: True") # setup db's for mapping short codes to IMAP msg ids (and reversed) self.postdb = self.dbenv.open_db(b"postdb") self.postdb_rev = self.dbenv.open_db(b"postdb_rev") def _connect_imap(self): """ connect / reconnect to imap server """ try: self.imapserv.select_folder("INBOX") except self.imapserv.AbortError as err: self.log.debug("reconnecting after imap connection error: ", err) self.imapserv.logout() self.imapserv = imapclient.IMAPClient(self.host, use_uid=True, ssl=self.imap_ssl) self.imapserv.login(self.login, self.password) self.imapserv.select_folder("INBOX") except self.imapserv.Error as err: self.log.debug("IMAP connection error: %err", err) self.smprint("IMAP connection error: " + str(err)) def _make_msg_object(self, imap_id, resp_obj): """ util tidies up message headers deals with encoding """ internaldate = resp_obj[b'INTERNALDATE'] msg_data = resp_obj[imap_id][b'RFC822'].decode('utf-8') msg_obj = email.message_from_string(msg_data) # format and tidy header data msg = Message() msg.fromstr = clean_header(msg_obj['From']) msg.tostr = clean_header(msg_obj['To']) msg.subject = clean_header(msg_obj['Subject']) if 'Cc' in msg_obj: msg.cc = clean_header(msg_obj['Cc']) else: msg.cc = None msg.date = internaldate msg.attachments = [] # TODO: deal with attachments # TODO: should use msg_obj.get_body and deal with HTML msg.content = "" # extract other sub-messages / attachments for part in msg_obj.walk(): # TODO: save interesting attachments to files # should clean these up on delete if part.get_content_type() == "text/plain": msg.content = part.get_payload(decode=True) print("msg content:", msg.content) msg.content = msg.content.decode("utf-8") msg.content = msg.content.replace("<", "<") msg.content = msg.content.replace(">", ">") msg.content = msg.content.replace("\n", "
") return msg def get_imap_id(self, msgid): """ take short code and look up the IMAP message id """ with self.dbenv.begin(db=self.postdb) as txn: imap_id = txn.get(msgid.encode()) return int(imap_id) def _imap2shortcode(self, msgid): """ returns a short code for a given IMAP id (creating a new mapping if needed) """ msgid = str(msgid).encode() with self.dbenv.begin(db=self.postdb_rev, write=True) as txn: shortcode = txn.get(msgid) if shortcode is not None: return shortcode.decode("utf-8") else: shortcode = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(5)).encode() print("saving msgid", msgid, shortcode) txn.put(msgid, shortcode) with self.dbenv.begin(db=self.postdb, write=True) as txn: txn.put(shortcode, msgid) return shortcode.decode("utf-8") @add_command("fax", "Send a Document Camera Image via Email") def fax(self, data): """ Takes a photograph using the document camera and sends it in an E-mail. """ photo = self.send("PXphoto.") @add_command("r", "Print full email") def read(self, data): """ Prints out the full detailed version of an email. """ shortcode, _ = data.split(".") imap_id = self.get_imap_id(shortcode) self._connect_imap() resp = self.imapserv.fetch([imap_id], ['INTERNALDATE', 'RFC822']) print("got imap msg:", imap_id, resp) msg = self._make_msg_object(imap_id, resp) # TODO: switch this to inline svgs # make action barcodes msg.un_bc = os.path.join("/tmp", "POun." + shortcode + ".png") kode256.image("POun." + shortcode).save(msg.un_bc) msg.d_bc = os.path.join("/tmp", "POd." + shortcode + ".png") kode256.image("POd." + shortcode).save(msg.d_bc) msg.sp_bc = os.path.join("/tmp", "POsp." + shortcode + ".png") kode256.image("POsp." + shortcode).save(msg.sp_bc) msg.re_bc = os.path.join("/tmp", "POre." + shortcode + ".png") kode256.image("POre." + shortcode).save(msg.re_bc) msg.rea_bc = os.path.join("/tmp", "POrea." + shortcode + ".png") kode256.image("POrea." + shortcode).save(msg.rea_bc) msg.r_bc = os.path.join("/tmp", "POr." + shortcode + ".png") kode256.image("POr." + shortcode).save(msg.r_bc) self.print_full("email.html", msg=msg, shortcode=shortcode) @add_command("d", "Delete email") def delete(self, data): """ Deletes an email and moves it to the trash folder. """ shortcode, _ = data.split(".") imap_id = self.get_imap_id(shortcode) self._connect_imap() self.imapserv.add_flags(imap_id, [imapclient.SEEN]) self.imapserv.copy((imap_id), self.trashfolder) self.imapserv.delete_messages((imap_id)) self.imapserv.expunge() @add_command("sp", "Mark as spam") def mark_spam(self, data): """ Flags an email as spam, mark as read and move it to the configured SPAM folder. """ shortcode, _ = data.split(".") imap_id = self.get_imap_id(shortcode) self._connect_imap() self.imapserv.add_flags(imap_id, [imapclient.SEEN]) self.imapserv.copy((imap_id), self.spamfolder) self.imapserv.delete_messages((imap_id)) self.imapserv.expunge() @add_command("un", "Mark as unread") def mark_unread(self, data): """ Flags an email as unseen (so you can deal with it later). """ shortcode, _ = data.split(".") imap_id = self.get_imap_id(shortcode) self._connect_imap() self.imapserv.remove_flags(imap_id, [imapclient.SEEN]) @add_command("re", "Reply with scan") def reply_scan(self, data): """ Reply to the sender of a mail with the PDF currently queued in the document scanner. """ # look up short code to get IMAP ID shortcode, _ = data.split(".") imap_id = self.get_imap_id(shortcode) self._connect_imap() # extract the sender and title resp = self.imapserv.fetch([imap_id], ['INTERNALDATE', 'RFC822']) internaldate = resp[imap_id][b'INTERNALDATE'] bodytext = """ This email is sent from a screenless office. If this is too wierd or awkward, just reply and let us find a more acceptable channel. http://screenl.es """ msg_data = resp[imap_id][b'RFC822'].decode('utf-8') msg_obj = email.message_from_string(msg_data) # put together the reply msg = email.mime.multipart.MIMEMultipart() msg["From"] = msg_obj["To"] # TODO: deal with ReplyTo headers properly msg["To"] = msg_obj["From"] msg["Subject"] = "Re: " + msg_obj["Subject"] #TODO: add reference headers from msg id for threading msg["Date"] = email.utils.formatdate(localtime=True) msg.attach(email.mime.text.MIMEText(bodytext)) # attach scanned image photo = self.send("PX", "photo")["photo"] with open(photo, "rb") as fil: base = os.path.basename(photo) part = email.mime.application.MIMEApplication(fil.read(), Name=base) part['Content-Disposition'] = 'attachment; filename="%s"' % base msg.attach(part) # send SMTP smtp = smtplib.SMTP(self.host, 587) smtp.starttls() smtp.login(self.login, self.password) smtp.sendmail(msg["From"], msg["To"], msg.as_string()) smtp.close() # flag as replied self.imapserv.add_flags(imap_id, [imapclient.ANSWERED]) @add_api("unread", "Get unread mails") def unread(self): """ Polls the currently configured IMAP server and returns a dict containing unread emails. """ self._connect_imap() messages = self.imapserv.sort("ARRIVAL", ["UNSEEN"]) print("%d unread messages in INBOX" % len(messages)) resp = self.imapserv.fetch(messages, ['FLAGS', 'INTERNALDATE', 'ENVELOPE', 'RFC822.SIZE']) # massage into a serializable dict msgs = [] for msgid, data in resp.items(): #msg = self._make_msg_object(msgid, data) msg = Message() shortcode = self._imap2shortcode(msgid) envelope = data[b"ENVELOPE"] msg.msgid = str(msgid) sender = envelope.from_[0] if sender.name is None: msg.fromname = "" else: msg.fromname = clean_header(sender.name.decode("utf-8")) msg.fromaddr = sender.mailbox + b"@" + sender.host msg.fromaddr = clean_header(msg.fromaddr.decode("utf-8")) msg.date = data[b"INTERNALDATE"].strftime("%d. %B %Y %I:%M%p") msg.subject = clean_header(envelope.subject.decode("utf-8")) # make action barcodes msg.d_bc = kode256.svg("POd." + shortcode) msg.sp_bc = kode256.svg("POsp." + shortcode) msg.r_bc = kode256.svg("POr." + shortcode) msgs.append(msg.__dict__) return msgs def main(): mr = MailRoom() mr.run() if __name__ == "__main__": main()