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.
369 lines
13 KiB
Python
369 lines
13 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 time
|
|
|
|
import imapclient
|
|
import kode256
|
|
from OpenSSL import SSL
|
|
|
|
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.last_login = 0
|
|
self._connect_imap()
|
|
else:
|
|
#TODO: do this with small print
|
|
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
|
|
"""
|
|
def run_login():
|
|
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")
|
|
|
|
# sessions die after 10 minutes so reconnect if it's stale
|
|
now = time.time()
|
|
if (now - self.last_login) > 600.0:
|
|
run_login()
|
|
self.last_login = now
|
|
return
|
|
|
|
# if we're already logged in just open the inbox
|
|
try:
|
|
self.imapserv.select_folder("INBOX")
|
|
except (self.imapserv.AbortError, SSL.SysCallError) as err:
|
|
self.log.debug("reconnecting after imap connection error: ", err)
|
|
run_login()
|
|
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
|
|
"""
|
|
#TODO: consider replacing this whole mess with
|
|
# https://pypi.python.org/pypi/mail-parser
|
|
internaldate = resp_obj[imap_id][b'INTERNALDATE']
|
|
|
|
msg_data = resp_obj[imap_id][b'RFC822'].decode('utf-8')
|
|
msg_obj = email.message_from_string(msg_data,
|
|
policy=email.policy.default)
|
|
|
|
# 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 = msg_obj.get_body(('plain',))
|
|
if msg.content:
|
|
msg.content = msg.content.get_content()
|
|
else:
|
|
msg.content = "ERROR COULD NOT FIND EMAIL BODY!\n Big shrug."
|
|
msg.content = msg.content.replace("<", "<")
|
|
msg.content = msg.content.replace(">", ">")
|
|
msg.content = msg.content.replace("\n", "<br />")
|
|
|
|
# extract other sub-messages / attachments
|
|
#for part in msg_obj.walk():
|
|
# # TODO: save interesting attachments to files
|
|
# # should clean these up on delete
|
|
# part_content = ""
|
|
# if part.get_content_type() == "text/plain":
|
|
# #part_content = part.get_content()
|
|
# part_content = part.get_content()
|
|
# print("msg part content:", part_content)
|
|
# # part_content = msg.content.decode("utf-8")
|
|
# part_content = msg.content.replace("<", "<")
|
|
# part_content = msg.content.replace(">", ">")
|
|
# part_content = msg.content.replace("\n", "<br />")
|
|
# msg.content += part_content
|
|
|
|
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)
|
|
|
|
# make action barcodes
|
|
msg.d_bc = os.path.join("/tmp", "POd." + shortcode + ".svg")
|
|
with open(msg.d_bc, "w") as svgfile:
|
|
svgfile.write(kode256.svg("POd." + shortcode))
|
|
msg.r_bc = os.path.join("/tmp", "POr." + shortcode + ".svg")
|
|
with open(msg.r_bc, "w") as svgfile:
|
|
svgfile.write(kode256.svg("POr." + shortcode))
|
|
msg.sp_bc = os.path.join("/tmp", "POd." + shortcode + ".svg")
|
|
with open(msg.sp_bc, "w") as svgfile:
|
|
svgfile.write(kode256.svg("POsp." + shortcode))
|
|
msg.un_bc = os.path.join("/tmp", "POun." + shortcode + ".svg")
|
|
with open(msg.un_bc, "w") as svgfile:
|
|
svgfile.write(kode256.svg("POun." + shortcode))
|
|
msg.re_bc = os.path.join("/tmp", "POre." + shortcode + ".svg")
|
|
with open(msg.re_bc, "w") as svgfile:
|
|
svgfile.write(kode256.svg("POre." + shortcode))
|
|
msg.rea_bc = os.path.join("/tmp", "POrea." + shortcode + ".svg")
|
|
with open(msg.rea_bc, "w") as svgfile:
|
|
svgfile.write(kode256.svg("POrea." + shortcode))
|
|
|
|
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 = os.path.join("/tmp", "POd." + shortcode + ".svg")
|
|
with open(msg.d_bc, "w") as svgfile:
|
|
svgfile.write(kode256.svg("POd." + shortcode))
|
|
msg.r_bc = os.path.join("/tmp", "POr." + shortcode + ".svg")
|
|
with open(msg.r_bc, "w") as svgfile:
|
|
svgfile.write(kode256.svg("POr." + shortcode))
|
|
msg.sp_bc = os.path.join("/tmp", "POsp." + shortcode + ".svg")
|
|
with open(msg.sp_bc, "w") as svgfile:
|
|
svgfile.write(kode256.svg("POsp." + shortcode))
|
|
|
|
msgs.append(msg.__dict__)
|
|
|
|
|
|
return msgs
|
|
|
|
|
|
def main():
|
|
mr = MailRoom()
|
|
mr.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|