You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

332 lines
12 KiB
Python

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("<", "&lt;")
header = header.replace(">", "&gt;")
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("<", "&lt;")
msg.content = msg.content.replace(">", "&gt;")
msg.content = msg.content.replace("\n", "<br />")
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()