#!/usr/bin/env python3# -*- coding: utf-8 -*-"""euler-smtp — loopback SMTP submission relay (docs/web-server-guide.md § Egress).A minimal, stdlib-only ESMTP listener on loopback that relays every acceptedmessage upstream (Gmail submission, STARTTLS + app password). It exists so thatthe app tier needs **no** direct-internet exception and **no** mail credentials: - It is the sole holder of the Gmail credentials (/etc/euler/smtp.env, root:euler-smtp 0640) and — via the nftables egress ruleset (scripts/setup/firewall.sh) — the sole euler-* uid permitted tcp :587. - euler-auth submits OTP/invite mail over plain loopback SMTP with no credentials of its own (EULER_SMTP_RELAY=127.0.0.1:8025). - The envelope sender is always forced to SMTP_ADDRESS; clients cannot choose an origin identity.Deliberately *not* a general MTA: no queue (a failed upstream send returns atransient 451 and the client retries), no TLS/auth on the loopback listener(loopback + the firewall's relay guard are the access control), tight size andrecipient limits. Message bodies are never logged (they carry OTPs).Deployed to /usr/local/bin/euler-smtp by scripts/setup/smtp.sh and run by theroot-owned euler-smtp.service as the dedicated `euler-smtp` user. Runs on thesystem python3 (stdlib only).Configuration (environment, from /etc/euler/smtp.env): EULER_SMTP_LISTEN listen address, default 127.0.0.1:8025 SMTP_HOST upstream submission host, default smtp.gmail.com SMTP_PORT upstream submission port, default 587 SMTP_ADDRESS Gmail address: upstream login user *and* forced sender SMTP_APP_PASSWORD Gmail app password (upstream login)Author: Vikas Munshi <vikas.munshi@gmail.com>Licensed under the MIT License."""from__future__importannotationsimportasyncioimportloggingimportosimportsmtplibimportsysfromtypingimportNamedTupleMAX_LINE=4096# RFC 5321 command lines are far shorterMAX_SIZE=64*1024# OTP/invite mail is tiny; cap DATA hardMAX_RCPT=5SESSION_TIMEOUT=60.0# seconds of inactivity before the connection is droppedlog=logging.getLogger('euler-smtp')classConfig(NamedTuple):"""The upstream submission settings, read once from the environment at startup. A NamedTuple rather than the `dict[str, object]` this used to be: every read then came back as `object` and had to be re-coerced at the call site — `str(...)` around values already known to be strings, and an `int(...)` around the port that no amount of casting made honest, since `int` takes no `object`. The dict cost a `type: ignore` for a type error that was never real. Here each field is typed once, where it is filled. """host:strport:intaddress:strpassword:strdef_require_env(name:str)->str:value=os.environ.get(name,'').strip()ifnotvalue:log.error('missing required environment variable %s',name)sys.exit(2)returnvaluedef_relay(sender:str,rcpts:list[str],data:bytes,host:str,port:int,password:str)->None:"""Submit one message upstream (blocking; run in a thread). Raises on failure."""withsmtplib.SMTP(host,port,timeout=30)assmtp:smtp.starttls()smtp.login(sender,password)smtp.sendmail(sender,rcpts,data)classSession:"""One loopback SMTP session: minimal ESMTP state machine."""def__init__(self,reader:asyncio.StreamReader,writer:asyncio.StreamWriter,config:Config)->None:self.reader=readerself.writer=writerself.config=configself.mail_from:str|None=Noneself.rcpts:list[str]=[]asyncdefsend(self,line:str)->None:self.writer.write((line+'\r\n').encode('ascii'))awaitself.writer.drain()asyncdefrecv_line(self)->bytes:"""Read one CRLF-terminated line, bounded by MAX_LINE and the idle timeout."""line=awaitasyncio.wait_for(self.reader.readline(),SESSION_TIMEOUT)iflen(line)>MAX_LINE:raiseValueError('line too long')returnlinedefreset(self)->None:self.mail_from=Noneself.rcpts=[]asyncdefrun(self)->None:awaitself.send('220 euler-smtp loopback relay ready')whileTrue:raw=awaitself.recv_line()ifnotraw:# EOF — client went awayreturnline=raw.decode('ascii','replace').rstrip('\r\n')verb,_,arg=line.partition(' ')verb=verb.upper()ifverbin('EHLO','HELO'):awaitself.send(f'250-euler-smtp\r\n250 SIZE {MAX_SIZE}')elifverb=='MAIL':# MAIL FROM:<...> — sender is *ignored* (forced)self.reset()self.mail_from=argawaitself.send('250 OK (sender is forced to the relay account)')elifverb=='RCPT':ifself.mail_fromisNone:awaitself.send('503 need MAIL first')eliflen(self.rcpts)>=MAX_RCPT:awaitself.send('452 too many recipients')else:addr=arg.partition(':')[2].strip().strip('<>')if'@'notinaddr:awaitself.send('501 bad recipient')else:self.rcpts.append(addr)awaitself.send('250 OK')elifverb=='DATA':ifnotself.rcpts:awaitself.send('503 need RCPT first')else:awaitself.handle_data()elifverb=='RSET':self.reset()awaitself.send('250 OK')elifverb=='NOOP':awaitself.send('250 OK')elifverb=='QUIT':awaitself.send('221 bye')returnelse:awaitself.send('502 command not implemented')asyncdefhandle_data(self)->None:awaitself.send('354 end with <CRLF>.<CRLF>')chunks:list[bytes]=[]size=0whileTrue:raw=awaitself.recv_line()ifnotraw:return# EOF mid-DATA: drop silentlyifraw.rstrip(b'\r\n')==b'.':breakifraw.startswith(b'.'):# dot-unstuffing (RFC 5321 §4.5.2)raw=raw[1:]size+=len(raw)ifsize>MAX_SIZE:awaitself.send('552 message too large')self.reset()returnchunks.append(raw)data=b''.join(chunks)sender=self.config.addressrcpts=list(self.rcpts)self.reset()try:awaitasyncio.to_thread(_relay,sender,rcpts,data,self.config.host,self.config.port,self.config.password)exceptExceptionasexc:# transient to the client; never log the bodylog.warning('relay failed: %d rcpt, %d bytes: %s',len(rcpts),len(data),exc)awaitself.send('451 upstream submission failed, try again later')returnlog.info('relayed: %d rcpt, %d bytes',len(rcpts),len(data))awaitself.send('250 OK message relayed')asyncdefhandle(reader:asyncio.StreamReader,writer:asyncio.StreamWriter,config:Config)->None:try:awaitSession(reader,writer,config).run()except(asyncio.TimeoutError,ValueError,ConnectionError):pass# idle/oversized/vanished client — just drop itexceptException:log.exception('session error')finally:writer.close()try:awaitwriter.wait_closed()except(ConnectionError,OSError):passasyncdefmain()->None:logging.basicConfig(level=logging.INFO,format='%(name)s: %(message)s')listen=os.environ.get('EULER_SMTP_LISTEN','127.0.0.1:8025')bind_host,_,bind_port=listen.rpartition(':')config=Config(host=os.environ.get('SMTP_HOST','smtp.gmail.com'),port=int(os.environ.get('SMTP_PORT','587')),address=_require_env('SMTP_ADDRESS'),password=_require_env('SMTP_APP_PASSWORD'),)server=awaitasyncio.start_server(lambdar,w:handle(r,w,config),bind_host,int(bind_port),limit=MAX_LINE)log.info('listening on %s, upstream %s:%s as %s',listen,config.host,config.port,config.address)asyncwithserver:awaitserver.serve_forever()if__name__=='__main__':try:asyncio.run(main())exceptKeyboardInterrupt:pass