diff options
| author | Vosjedev <vosje@vosjedev.net> | 2025-11-06 15:33:30 +0100 |
|---|---|---|
| committer | Vosjedev <vosje@vosjedev.net> | 2025-11-06 15:33:30 +0100 |
| commit | 8d8375b2c309c54a734e2d3b8cbd9b35ac664e74 (patch) | |
| tree | 1fe6549cf19855d4d3cce1c8df6abff6127fcbdc | |
| parent | f13fc2943d1292bf31a9fe8112dc3729e29280eb (diff) | |
| download | acit-8d8375b2c309c54a734e2d3b8cbd9b35ac664e74.tar.gz acit-8d8375b2c309c54a734e2d3b8cbd9b35ac664e74.tar.bz2 acit-8d8375b2c309c54a734e2d3b8cbd9b35ac664e74.tar.xz | |
smtp: allow sending email using an smtp queue
| -rw-r--r-- | src/acit/smtpplugin.py | 207 |
1 files changed, 207 insertions, 0 deletions
diff --git a/src/acit/smtpplugin.py b/src/acit/smtpplugin.py new file mode 100644 index 0000000..3b9cc81 --- /dev/null +++ b/src/acit/smtpplugin.py @@ -0,0 +1,207 @@ + +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, + tosend:IMAP_Message|Message=None, + save=False, + ): + """ + 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): force adding 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) + if bcc: + tosend["Resent-Bcc"]=", ".join(bcc) + if cc: + tosend["Resent-Cc"]=", ".join(bcc) + + if not from_ in tosend["to"]+tosend.get("cc","")+tosend.get("resent-to",""): + if "cc" in tosend: + tosend["cc"]+=", "+from_ + else: + tosend["cc"]=from_ + + tosend["x-acit-delete-when-sender"]=from_ + + else: + + if replyto: + cc.extend(replyto.cc) + cc.extend(replyto.to) + 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 to: + if addr in cc: + cc.remove(addr) + for addr in cc: + if addr in bcc: + bcc.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 + tosend["Cc"]=", ".join(set(cc)) + tosend["Bcc"]=", ".join(set(bcc)) + + if replyto: + 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"] + + tosend.set_payload(body) + + + tosend["Sender"]=from_ + tosend["X-Acit-Is-Outgoing"]=from_ + if not "Message-ID" in tosend: + tosend["Message-ID"]="<"+datetime.now().strftime("%d%m%Y%H%M%S%j")+"@"+self.domain+">" + + if save and not from_ in tosend["Cc"]: + tosend["Cc"]+=" "+from_ + + self.que.put(tosend) + + return tosend["Message-ID"] + + + 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: + item=self.que.get(timeout=1) + if not item: + continue + except Empty: + if smtp: + smtp.quit() + smtp=None + if self.stopping.is_set(): + break + continue + + try: + 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) + + 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) + |
