aboutsummaryrefslogtreecommitdiffstats

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)