from typing import Callable
import cherrypy
import threading
from os import getenv
from queue import Queue, Empty
from imap_tools import MailMessage as IMAP_Message
from email.message import Message
from email import message_from_bytes, utils
from smtplib import SMTP_SSL as SMTP
from smtplib import (
SMTPResponseException,
SMTPDataError,
SMTPConnectError,
SMTPServerDisconnected,
SMTPHeloError,
SMTPAuthenticationError
)
from datetime import datetime
class SmtpPlugin():
def __init__(self):
self.smtp_server=getenv("ACIT_SMTP_SERVER")
self.smtp_user=getenv("ACIT_SMTP_USER")
self.smtp_pass=getenv("ACIT_SMTP_PASS")
self.smtp_port=int(getenv("ACIT_SMTP_PORT",0))
self.domain="whyisthisattributeunset.example.com"
self.thread:threading.Thread=None
self.stopping=threading.Event()
self.que=Queue()
def sendmail(self,
from_:str=None,
to:str|list[str]=[],
subject:str=None,
body:str=None,
cc:list[str]=[],
bcc:list[str]=[],
replyto:IMAP_Message=None,
replytoall:bool=True,
tosend:IMAP_Message|Message=None,
save=False,
extraheaders:dict={},
overwriteheaders:dict={},
postprocessor:Callable=None,
smtp_to:list|str=None,
):
"""
Required arguments:
if ``tosend`` is unset:
- body, from_
- if ``replyto`` is also unset, also:
- to, subject
if ``tosend`` is set:
- from_
from_ (str): the email address to use for the ``From`` header. if ``tosend`` is set, used for ``Resent-From`` instead, and added to ``Cc``.
to (list|str): the email address(es) to send to. when specifying multiple addrs, use a list.
subject (str): the subject for the email. not needed when ``replyto`` is set, though when both are set, ``subject`` is used.
body (str): the email body
cc (list): email addresses to put in the cc
save (bool): add from_ to the cclist, making ImapPlugin store the email in ``Sent`` on arrival
"""
if type(to)==str:
to=[to]
if tosend:
if type(tosend)==IMAP_Message:
tosend=tosend.obj
tosend=message_from_bytes(tosend.as_bytes(), policy=tosend.policy) # copy message object
tosend["Resent-From"]=from_
tosend["Resent-Date"]=utils.format_datetime(datetime.now())
tosend["Resent-To"]=", ".join(to)
tosend["Resent-Message-ID"]=self.gen_msg_id()
if bcc:
tosend["Resent-Bcc"]=", ".join(bcc)
if cc:
tosend["Resent-Cc"]=", ".join(bcc)
if not save:
tosend["X-Acit-Delete-When-Sender"]=from_
else:
if replyto:
if replytoall:
cc.extend(replyto.cc)
cc.extend(replyto.to)
if from_ in cc: cc.remove(from_)
if from_ in bcc: bcc.remove(from_)
to.append(replyto.headers["reply-to"] if "reply-to" in replyto.headers else replyto.from_)
if not subject:
subject="Re: "+replyto.subject
for addr in cc:
if addr in bcc:
bcc.remove(addr)
for addr in to:
if addr in cc:
cc.remove(addr)
if not (to and body and from_ and subject):
raise ValueError("Missing to, from_, body, or subject")
tosend=Message()
tosend["From"]=from_
tosend["To"]=", ".join(set(to)) # set()s can't have duplicates
tosend["Subject"]=subject
if cc: tosend["Cc"]=", ".join(set(cc))
if bcc: tosend["Bcc"]=", ".join(set(bcc))
if replyto:
self.format_reply_headers(replyto=replyto,tosend=tosend) # tosend is mutable so we can ignore the return value
tosend.set_payload(body)
tosend["Sender"]=from_
tosend["X-Acit-Is-Outgoing"]=from_
if not "message-id" in tosend:
tosend["Message-ID"]=self.gen_msg_id()
if save and not from_ in tosend["Cc"]:
tosend["Cc"]+=" "+from_
for key,value in extraheaders.items():
tosend[key]=value
for key,value in overwriteheaders.items():
del tosend[key]
tosend[key]=value
if callable(postprocessor):
postprocessor(tosend)
self.que.put({"msg":tosend,"real-to":smtp_to})
import traceback
function=traceback.extract_stack(limit=2)[0].name
self.mlog("sendmail called by",function,"for",tosend["message-id"])
return tosend["message-id"]
def gen_msg_id(self):
return "<"+datetime.now().strftime("%d%m%Y%H%M%S%j")+"@"+self.domain+">"
def format_reply_headers(self,replyto:IMAP_Message,tosend:Message|dict={}):
if "references" in replyto.headers:
tosend["References"]=replyto.obj["references"]
elif "in-reply-to" in replyto.headers:
tosend["References"]=replyto.obj["in-reply-to"]
if "message-id" in replyto.headers:
tosend["In-Reply-To"]=replyto.obj["message-id"]
if "References" in tosend:
tosend["References"]+=" "+replyto.obj["message-id"]
else:
tosend["References"]=replyto.obj["message-id"]
return tosend
def start(self):
self.thread=threading.Thread(target=self.runner)
self.thread.start()
def runner(self):
threading.current_thread().name="SendMailRunner"
smtp=None
while True:
try:
data=self.que.get(timeout=1)
if not data:
continue
except Empty:
if smtp:
smtp.quit()
smtp=None
if self.stopping.is_set():
break
continue
try:
item=data["msg"]
if not smtp:
self.mlog("Connecting and logging in to smtp server")
smtp=SMTP(host=self.smtp_server,port=self.smtp_port)
smtp.ehlo()
smtp.login(user=self.smtp_user,password=self.smtp_pass)
self.mlog("Sending message %s '%s'"%(item.get("message-id"),item.get("subject")))
smtp.send_message(item, to_addrs=data["real-to"])
except SMTPAuthenticationError:
self.mlog("!!! AUTH ERROR !!! please check your config. Discarding email.")
except (SMTPHeloError,SMTPResponseException,SMTPDataError,SMTPServerDisconnected,SMTPConnectError) as e:
self.mlog("Error occured: %s"%repr(e))
self.que.put(item)
self.mlog("Sending rescheduled.")
except Exception:
if smtp:
smtp.quit()
smtp=None
import traceback
self.mlog("An error occured in the SMTP runner:\n",traceback.format_exc())
self.mlog("As a result, the following email was discarded: %s '%s'"%(item.get("message-id"),item.get("subject")))
finally:
self.que.task_done()
def stop(self,block=True):
self.mlog("Signaling stop to sendmail")
self.stopping.set()
if block:
self.thread.join()
def mlog(self,*msg,**kwargs):
import traceback
function=traceback.extract_stack(limit=2)[0].name
cherrypy.log(context="%s>>SENDMAIL:%s"%(threading.current_thread().name, function), msg=" ".join([str(i) for i in msg]),**kwargs)