Skip to content

Commit 69e6259

Browse files
committed
Basic email handling
o DKIM signing o Direct SMTP sending o Postfix-compatible post-queue filtering autoresponder
1 parent 1547a70 commit 69e6259

File tree

4 files changed

+330
-19
lines changed

4 files changed

+330
-19
lines changed

requirements-tests.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
flake8
22
pytest >=7.2.0,<8
33
pytest-cov >=4.0.0,<5
4+
aiosmtpd >=1.4, <2

slip39/invoice/email.py

Lines changed: 202 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
1+
#! /usr/bin/env python3
22
#
33
# Python-slip39 -- Ethereum SLIP-39 Account Generation and Recovery
44
#
@@ -15,18 +15,22 @@
1515
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
1616
#
1717
import logging
18+
import re
1819
import smtplib
1920
import ssl
21+
import sys
2022

21-
from tabulate import tabulate
22-
from email import utils
23-
from email.mime import multipart, text
23+
from subprocess import Popen, PIPE
2424

25+
import click
2526
import dkim
2627

28+
from tabulate import tabulate
29+
from email import utils, message_from_file
30+
from email.mime import multipart, text
2731
from crypto_licensing.licensing import doh
2832

29-
from ..util import is_listlike, commas
33+
from ..util import is_listlike, commas, uniq, log_cfg, log_level
3034

3135

3236
__author__ = "Perry Kundert"
@@ -170,10 +174,10 @@ def send_message(
170174
from_addr = None, # Envelope MAIL FROM: (use msg['Sender'/'From'] if not specified)
171175
to_addrs = None, # Envelope RCTP TO: (use msg['To'/'CC'/'BCC'] if not specified)
172176
relay = None, # Eg. "localhost"; None --> lookup MX record
173-
port = 587, # Eg. 25 --> raw TCP/IP, 587 --> TLS, 465 --> SSL
174-
starttls = True, # Upgrade SMTP connection w/ TLS
175-
verifycert = False, # and verify SSL/TLS certs (not generally supported)
176-
usessl = False, # Connect using SMTP_SSL
177+
port = None, # Eg. 25 --> raw TCP/IP, 587 --> TLS (default), 465 --> SSL
178+
starttls = None, # Upgrade SMTP connection w/ TLS (default: True iff port == 587)
179+
usessl = None, # Connect using SMTP_SSL (default: True iff port == 465)
180+
verifycert = None, # and verify SSL/TLS certs (not generally supported)
177181
username = None,
178182
password = None,
179183
):
@@ -205,8 +209,8 @@ def send_message(
205209
# Now that we have a to_addrs, construct a mapping of (mx, ...) --> [addr, ...]. For each
206210
# to_addrs, lookup its destination's mx records; we'll append all to_addrs w/ the same mx's
207211
# (sorted by priority).
212+
relay_addrs = {}
208213
if relay is None:
209-
relay_addrs = dict()
210214
for to in to_addrs:
211215
relay_addrs.setdefault( tuple( mx_records( to.split( '@', 1 )[1] )), [] ).append( to )
212216
else:
@@ -215,7 +219,7 @@ def send_message(
215219
relay_addrs[tuple( relay )] = to_addrs
216220
relay_max = max( len( r ) for r in relay_addrs.keys() )
217221
addrs_max = max( len( a ) for a in relay_addrs.values() )
218-
log.info( f"Relays, and their destination addresses\n" + tabulate(
222+
log.info( "Relays, and their destination addresses\n" + tabulate(
219223
[
220224
list( r ) + ([ None ] * (relay_max - len( r ))) + list( a )
221225
for r,a in relay_addrs.items()
@@ -224,6 +228,16 @@ def send_message(
224228
tablefmt='orgtbl'
225229
))
226230

231+
# Default port and TLS/SSL if unspecified.
232+
if port is None:
233+
port = 587
234+
if starttls is None:
235+
starttls = True if port == 587 else False
236+
if usessl is None:
237+
usessl = True if port == 465 else False
238+
if verifycert is None:
239+
verifycert = False
240+
227241
try:
228242
# Python 3 libraries expect bytes.
229243
msg_data = msg.as_bytes()
@@ -287,3 +301,180 @@ def getreply( self ):
287301
log.info( f"{relayed} of {len( relay_addrs )} relays succeeded" )
288302

289303
return msg
304+
305+
306+
class postqueue:
307+
"""A postfix-compatible post-queue filter. See:
308+
https://codepoets.co.uk/2015/python-content_filter-for-postfix-rewriting-the-subject/
309+
310+
Receives an email via stdin, and re-injects it into the mail system via /usr/sbin/sendmail,
311+
raising an exception if for any reason it is unable to do so (caller should trap and return
312+
an appropriate exit status compatible w/ postfix:
313+
314+
0: success
315+
69: bounce
316+
75: tempfail
317+
318+
Creates and sends a response email via SMTP to a relay (default is the local SMTP server at
319+
localhost:25)
320+
321+
The msg, from_addr and to_addrs are retained in self.msg, etc.
322+
323+
Postfix will pass all To:, Cc: and Bcc: recipients in to_addrs.
324+
325+
The reinject command is executed/called, and passed from_addr and *to_addrs.
326+
"""
327+
def __init__( self, reinject=None ):
328+
if reinject is None:
329+
reinject = [ '/usr/bin/sendmail', '-G', '-i', '-f' ]
330+
self.reinject = reinject
331+
332+
def respond( self, from_addr, *to_addrs ):
333+
msg = self.message()
334+
try:
335+
# Allow a command (list), or a function for reinject
336+
if is_listlike( self.reinject ):
337+
with Popen( self.reinject + [ from_addr] + to_addrs, stdin=PIPE ) as process:
338+
process.communicate( msg.as_bytes() )
339+
else:
340+
self.reinject( from_addr, *to_addrs )
341+
except Exception as exc:
342+
log.warning( f"Re-injecting message From: {from_addr}, To: {commas( to_addrs )} via sendmail failed: {exc}" )
343+
raise
344+
return msg
345+
346+
def message( self ):
347+
"""Return (a possibly altered) email.Message as the auto-response. By default, an filter
348+
receives its email.Message from stdin, unaltered.
349+
350+
"""
351+
msg = message_from_file( sys.stdin )
352+
return msg
353+
354+
def response( self, msg ):
355+
"""Prepare the response message. Normally, it is at least just a different message."""
356+
msg
357+
return msg
358+
359+
360+
def matchaddr( address, mailbox=None, domain=None ):
361+
"""The supplied address begins with "<mailbox>", optionally followed by a "+<extension>", and
362+
then ends with "@<domain>". If so, return the re.match w/ the 3 groups. If either 'mailbox' or
363+
'domain' is falsey, any will be allowed.
364+
365+
Does not properly respect email addresses with quoting, eg. 'abc"123@456"@domain.com'
366+
"""
367+
_,email = utils.parseaddr( address )
368+
return re.match(
369+
rf"(^{mailbox if mailbox else '[^@+]*'})(?:\+([^@]+))?@({domain if domain else '.*'})",
370+
email,
371+
re.IGNORECASE
372+
)
373+
374+
375+
class autoresponder( postqueue ):
376+
def __init__( self, *args, address=None, server=None, port=None, **kwds ):
377+
m = matchaddr( address or '' )
378+
assert m, \
379+
f"Must supply a valid email destination address to auto-respond to: {address}"
380+
self.address = address
381+
self.mailbox,self.extension,self.domain = m.groups()
382+
self.relay = 'localhost' if server is None else server
383+
self.port = 25 if port is None else port
384+
385+
super().__init__( *args, **kwds )
386+
387+
log.info( f"autoresponding to DKIM-signed emails To: {self.address}@{self.domain}" )
388+
389+
def respond( self, from_addr, *to_addrs ):
390+
"""Decide if we should auto-respond, and do so."""
391+
392+
msg = super().respond( from_addr, *to_addrs )
393+
log.info( f"Filtered From: {msg['from']}, To: {msg['to']}"
394+
f" originally from {from_addr} to {len(to_addrs)} recipients: {commas( to_addrs, final='and' )}" )
395+
396+
if 'to' not in msg or not matchaddr( msg['to'], mailbox=self.mailbox, domain=self.domain ):
397+
log.warning( f"Message From: {msg['from']}, To: {msg['to']} expected To: {self.address}; not auto-responding" )
398+
return 0
399+
if 'dkim-signature' not in msg:
400+
log.warning( f"Message From: {msg['from']}, To: {msg['to']} is not DKIM signed; not auto-responding" )
401+
return 0
402+
if not dkim.verify( msg.as_bytes() ):
403+
log.warning( f"Message From: {msg['from']}, To: {msg['to']} DKIM signature fails; not auto-responding " )
404+
return 0
405+
406+
# Normalize, uniqueify and filter the addresses (discarding invalids). Avoid sending it to
407+
# the designated self.address, as this would set up a mail loop.
408+
to_addrs_filt = [
409+
fa
410+
for fa in uniq( filter( None, (
411+
utils.parseaddr( a )[1]
412+
for a in to_addrs
413+
)))
414+
if fa != self.address
415+
]
416+
417+
rsp = self.response( msg )
418+
# Also use the Reply-To: address, if supplied
419+
if 'reply-to' in rsp:
420+
_,reply_to = utils.parseaddr( rsp['reply-to'] )
421+
if reply_to not in to_addrs_filt:
422+
to_addrs_filt.append( reply_to )
423+
424+
log.info( f"Response From: {rsp['from']}, To: {rsp['to']}"
425+
f" autoresponding from {from_addr} to {len(to_addrs_filt)} recipients: {commas( to_addrs_filt, final='and' )}"
426+
+ ( f" (was {len(to_addrs)}: {commas( to_addrs, final='and' )})" if to_addrs != to_addrs_filt else "" ))
427+
428+
# Now, send the same message to all the supplied Reply-To, and Cc/Bcc address (already in
429+
# responder.to_addrs). If it is DKIM signed, since we're not adjusting the message -- just
430+
# send w/ new RCPT TO: envelope addresses. We'll use the same from_addr address (it must be
431+
# from our domain, or we wouldn't be processing it.
432+
try:
433+
send_message(
434+
rsp,
435+
from_addr = from_addr,
436+
to_addrs = to_addrs_filt,
437+
relay = self.relay,
438+
port = self.port,
439+
)
440+
except Exception as exc:
441+
log.warning( f"Message From: {rsp['from']}, To: {rsp['to']} autoresponder SMTP send failed: {exc}" )
442+
return 75 # tempfail
443+
444+
return 0
445+
446+
447+
@click.group()
448+
@click.option('-v', '--verbose', count=True)
449+
@click.option('-q', '--quiet', count=True)
450+
@click.option( '--json/--no-json', default=True, help="Output JSON (the default)")
451+
def cli( verbose, quiet, json ):
452+
cli.verbosity = verbose - quiet
453+
log_cfg['level'] = log_level( cli.verbosity )
454+
logging.basicConfig( **log_cfg )
455+
if verbose or quiet:
456+
logging.getLogger().setLevel( log_cfg['level'] )
457+
cli.json = json
458+
cli.verbosity = 0 # noqa: E305
459+
cli.json = False
460+
461+
462+
@click.command()
463+
@click.argument( 'address' )
464+
@click.option( '--server', default='localhost' )
465+
@click.option( '--port', default=25 )
466+
def respond( address, server, port ):
467+
"""Run an auto-responder that replies to all incoming emails to the specified email address.
468+
469+
- Must be DKIM signed, including the From: and To: addresses.
470+
- The RCPT TO: "envelope" address must match 'address':
471+
- We won't autorespond to copies of the email being delivered to other inboxes
472+
- The MAIL FROM: "envelope" address must match the From: address
473+
- We won't autorespond to copies forwarded from other email addresses
474+
475+
"""
476+
pass
477+
478+
479+
if __name__ == "__main__":
480+
cli()

0 commit comments

Comments
 (0)