Skip to content

Commit 5fec6e2

Browse files
committed
Filter AutoResponder on "From: " address (already filtered on To:)
1 parent 1cd5ba2 commit 5fec6e2

File tree

2 files changed

+143
-59
lines changed

2 files changed

+143
-59
lines changed

slip39/invoice/communications.py

Lines changed: 109 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import ssl
2222
import sys
2323

24+
from fnmatch import fnmatch
2425
from subprocess import Popen, PIPE
2526

2627
import click
@@ -58,10 +59,29 @@ def mx_records( domain, timeout=None ):
5859
yield mx
5960

6061

61-
def matchaddr( address, mailbox=None, domain=None ):
62-
"""The supplied email address begins with "<mailbox>", optionally followed by a "+<extension>", and
63-
then ends with "@<domain>". If so, return the re.match w/ the 3 groups. If either 'mailbox' or
64-
'domain' is falsey, any will be allowed.
62+
def matchglob( string, pattern ):
63+
"""Returns the 'string' matching the Glob 'pattern', nor None."""
64+
if set( '*?[' ) & set( pattern ):
65+
if fnmatch( string, pattern ):
66+
return string
67+
elif pattern == string:
68+
return string
69+
70+
71+
def matchaddr( address, mailbox=None, extension=None, domain=None ):
72+
"""Parse and email address (eg. from "Real Name <[email protected]>"), and return its
73+
(<mailbox>,<extension>,<domain>), or None if no address parsed/matched.
74+
75+
The supplied email address begins with "<mailbox>", optionally followed by a "+<extension>", and
76+
then ends with "@<domain>". If so, return the re.match's 3 groups. Note: a group may be None
77+
if not present, ie. "[email protected]" returns ("someone", None, "domain.com"). If 'mailbox',
78+
'extension' or 'domain' are falsey, any will be allowed.
79+
80+
Allows Glob-style patterns in the supplied 'mailbox', 'extension' or 'domain', eg:
81+
82+
domain="dominion*.c*"
83+
84+
would match email addresses to both "[email protected]" and "[email protected]"
6585
6686
Does not properly respect email addresses with quoting, eg. 'abc"123@456"@domain.com' because,
6787
quite frankly, I don't want to and that's just "Little Bobby Tables" (https://xkcd.com/327/)
@@ -70,11 +90,14 @@ def matchaddr( address, mailbox=None, domain=None ):
7090
Simple <mailbox>[+<extension>]@<domain>, please.
7191
7292
"""
73-
return re.match(
74-
rf"(^{mailbox if mailbox else '[^@+]*'})(?:\+([^@]+))?@({domain if domain else '.*'})",
93+
m = re.match(
94+
r"(^[^@+]+)(?:\+([^@]+))?@(.*)",
7595
utils.parseaddr( address )[1],
76-
re.IGNORECASE
7796
)
97+
if ( not mailbox or ( m.group( 1 ) and matchglob( m.group( 1 ).lower(), mailbox.lower() ))):
98+
if ( not extension or ( m.group( 2 ) and matchglob( m.group( 2 ).lower(), extension.lower() ))):
99+
if ( not domain or ( m.group( 3 ) and matchglob( m.group( 3 ).lower(), domain.lower() ))):
100+
return m.groups()
78101

79102

80103
def dkim_message(
@@ -117,7 +140,7 @@ def dkim_message(
117140
if headers is None:
118141
headers = ["From", "To", "Subject"]
119142

120-
sender_domain = matchaddr( sender_email ).group( 3 )
143+
sender_domain = matchaddr( sender_email )[2]
121144

122145
msg = multipart.MIMEMultipart("alternative")
123146
msg.attach(text.MIMEText(message_text, "plain"))
@@ -428,24 +451,37 @@ def response( self, msg, new_id=None ):
428451
# Prefer the sender field per RFC 2822:3.6.2.
429452
sender_m = matchaddr( rsp['Sender'] if 'Sender' in rsp else rsp['From'] )
430453
if sender_m:
431-
domain = sender_m.group( 3 )
454+
domain = sender_m[2]
432455
rsp['Message-ID'] = utils.make_msgid( domain=domain )
433456
return rsp
434457

435458

436459
class AutoResponder( PostQueue ):
460+
"""Filter DKIM-signed emails by Sender:/From: address, and auto-respond by sending a copy to all
461+
Cc/Bcc/Reply-To recipients.
462+
463+
This is useful when incoming emails generated by an automated process have been DKIM-signed, and
464+
can therefore not have their To: field modified; there is no generally accepted method for delivering
465+
such emails to multiple parties.
466+
467+
Once we are reasonably assured that this email is going to be delivered (eg. if it carries a
468+
financially costly payload, such as a Cryptocurrency wallet address, or the means to derive it),
469+
we may use this filter to (also) inform the intended recipient(s) (eg. in the Cc/Bcc/Reply-To)
470+
to proceed with the payment into the designated Cryptocurrency account.
471+
472+
"""
437473
def __init__( self, *args, address=None, server=None, port=None, **kwds ):
438-
m = matchaddr( address or '' )
439-
assert m, \
440-
f"Must supply a valid email destination address to auto-respond to: {address}"
474+
address_m = matchaddr( address or '' )
475+
assert address_m, \
476+
f"Must supply a valid email From: address to auto-respond to: {address}"
441477
self.address = address
442-
self.mailbox,self.extension,self.domain = m.groups()
478+
self.mailbox,self.extension,self.domain = address_m
443479
self.relay = 'localhost' if server is None else server
444480
self.port = 25 if port is None else port
445481

446482
super().__init__( *args, **kwds )
447483

448-
log.info( f"autoresponding to DKIM-signed emails To: {self.address}@{self.domain}" )
484+
log.info( f"autoresponding to DKIM-signed emails From: {self.address}@{self.domain}" )
449485

450486
def __call__( self, from_addr, *to_addrs ):
451487
"""Decide if we should auto-respond, and do so. Return the email.Message, and an appropriate
@@ -460,40 +496,50 @@ def __call__( self, from_addr, *to_addrs ):
460496
log.info( f"Filtered From: {self.msg['From']}, To: {self.msg['To']}"
461497
f" originally from {from_addr} to {len(to_addrs)} recipients: {commas( to_addrs, final='and' )}" )
462498

463-
# Detect if this is a message we are intended to autorespond to; if not, do nothing.
464-
if 'To' not in self.msg or not matchaddr( self.msg['To'], mailbox=self.mailbox, domain=self.domain ):
465-
log.warning( f"Message From: {self.msg['From']}, To: {self.msg['To']} expected To: {self.address}; not auto-responding" )
499+
# Detect if this is a message we are intended to autorespond to; if not, do nothing. First thing, ensure
500+
# that its Sender:/From: matches address (allows Glob wildcards).
501+
from_m = matchaddr(
502+
self.msg['Sender'] or self.msg['From'],
503+
mailbox = self.mailbox,
504+
extension = self.extension,
505+
domain = self.domain
506+
)
507+
if not from_m:
508+
log.warning( f"Message From: {self.msg['From']}, To: {self.msg['To']} expected Sender:/From: {self.address}; not auto-responding" )
466509
return 0
467-
if 'dkim-signature' not in self.msg:
510+
if 'DKIM-Signature' not in self.msg:
468511
log.warning( f"Message From: {self.msg['From']}, To: {self.msg['To']} is not DKIM signed; not auto-responding" )
469512
return 0
470513
if not dkim.verify( self.msg.as_bytes() ):
471514
log.warning( f"Message From: {self.msg['From']}, To: {self.msg['To']} DKIM signature fails; not auto-responding " )
472515
return 0
473516

474517
# This message is a target: Normalize, uniqueify and filter the addresses (discarding
475-
# invalids). Avoid sending it to the designated self.address, as this would set up a mail
476-
# loop.
518+
# invalids). Avoid sending it to the designated To: address as this would likely set up a
519+
# mail loop.
520+
to_addr = utils.parseaddr( self.msg['To'] )[1] # May be empty, if no To: in header
477521
to_addrs_filt = [
478522
fa
479523
for fa in uniq( filter( None, (
480524
utils.parseaddr( a )[1]
481525
for a in to_addrs
482526
)))
483-
if fa != self.address
527+
if fa != to_addr
484528
]
485529

486530
# Get the outgoing message; also use the Reply-To: address, if supplied
487531
self.rsp = self.response( self.msg )
488-
if 'reply-to' in self.rsp:
489-
_,reply_to = utils.parseaddr( self.rsp['reply-to'] )
532+
if 'Reply-To' in self.rsp:
533+
_,reply_to = utils.parseaddr( self.rsp['Reply-To'] )
490534
if reply_to not in to_addrs_filt:
491535
to_addrs_filt.append( reply_to )
492536
self.rsp_peers = (from_addr, tuple( to_addrs_filt ))
493-
494537
log.info( f"Response From: {self.rsp['From']}, To: {self.rsp['To']}"
495538
f" autoresponding from {from_addr} to {len(to_addrs_filt)} recipients: {commas( to_addrs_filt, final='and' )}"
496539
+ ( "" if set( to_addrs ) == set( to_addrs_filt ) else f" (was {len(to_addrs)}: {commas( to_addrs, final='and' )})" ))
540+
if not to_addrs_filt:
541+
log.warning( f"Message From: {self.msg['From']}, To: {self.msg['To']} has no additional recipients; not auto-responding " )
542+
return 0
497543

498544
# Now, send the same message to all the supplied Reply-To, and Cc/Bcc addresses (which were
499545
# already in to_addrs). If it is DKIM signed, it will remain so, since we're not adjusting
@@ -533,7 +579,7 @@ def cli( verbose, quiet, json ):
533579

534580

535581
@click.command()
536-
@click.argument( 'address', nargs=1 )
582+
@click.argument( 'address', nargs=1 ) # Only auto-respond to message with Sender:/From: this address
537583
@click.argument( 'from_addr', nargs=1 )
538584
@click.argument( 'to_addrs', nargs=-1 )
539585
@click.option( '--server', default='localhost' )
@@ -552,12 +598,42 @@ def autoresponder( address, from_addr, to_addrs, server, port, reinject ):
552598
- We won't autorespond to copies forwarded from other email addresses
553599
554600
555-
Configure Postfix system as per: https://github.com/innovara/autoreply, eg.:
601+
Build/install Python3.10
602+
$ wget https://www.python.org/ftp/python/3.10.9/Python-3.10.9.tgz
603+
$ sudo apt-get -u install wget build-essential libreadline-gplv2-dev libncursesw5-dev libssl-dev \
604+
libsqlite3-dev tk-dev libgdbm-dev libc6-dev libbz2-dev libffi-dev zlib1g-dev
605+
$ tar xzf Python-3.10.9.tgz
606+
$ cd Python-3.10.9
607+
$ ./configure --enable-optimizations --with-readline
608+
$ make
609+
$ make altinstall
610+
611+
Install python-slip39
612+
613+
# python3.10 -m pip install https://github.com/pjkundert/python-slip39/archive/feature-invoice.zip#egg=slip39[gui,wallet]
614+
615+
Configure Postfix system roughly as per: https://github.com/innovara/autoreply,
616+
to run our slip39.invoice.communications autoresponder
617+
618+
- Create 'autoreply' user, and /opt/autoreply (not presently used)
619+
- Only necessary if your filter uses filesystem; otherwise, use 'nobody'
620+
621+
- Configure /etc/postfix/master.cf to run the autoreply filter
556622
557623
# autoresponder pipe
558624
autoreply unix - n n - - pipe
559625
flags= user=autoreply null_sender=
560-
argv=python3 -m slip39.invoice.communication autoresponder [email protected] ${sender} ${recipient}
626+
argv=/usr/local/bin/python3.10 -m slip39.invoice.communication autoresponder [email protected] ${sender} ${recipient}
627+
628+
- Create /etc/postfix/autoreply w/ lines like (and postmap /etc/postfix/autoreply):
629+
630+
[email protected] FILTER autoreply:dummy
631+
632+
- Configure /etc/postfix/main.cf to process the autoreply filter
633+
634+
# Trigger the autoreply filter for specified recipient addresses
635+
smtpd_recipient_restrictions =
636+
check_recipient_access hash:/etc/postfix/autoreply
561637
562638
"""
563639
ar = AutoResponder(
@@ -567,25 +643,25 @@ def autoresponder( address, from_addr, to_addrs, server, port, reinject ):
567643
reinject = reinject,
568644
)
569645
status = ar( from_addr, *to_addrs )
570-
if cli.json:
646+
if cli.json and cli.verbosity:
571647
click.echo( json.dumps( dict(
572648
dict(
573649
dict(
574650
Original = ar.msg.as_string() if ar.msg else None,
575-
) if cli.verbosity > 1 else dict(),
651+
) if cli.verbosity > 2 else dict(),
576652
Response = ar.rsp.as_string() if ar.rsp else None,
577-
) if cli.verbosity > 0 else dict(),
653+
) if cli.verbosity > 1 else dict(),
578654
status = status,
579655
MAIL_FROM = ar.rsp_peers[0],
580656
RCPT_TOs = ar.rsp_peers[1],
581657
), indent=4 ))
582-
else:
658+
elif cli.verbosity:
583659
click.echo( f"status: {status}" )
584660
click.echo( f"MAIL FROM: {ar.rsp_peers[0]}" )
585661
click.echo( f"RCPT TOs: {commas( ar.rsp_peers[1] )}" )
586-
if cli.verbosity > 1:
662+
if cli.verbosity > 2:
587663
click.echo( f"Original:\n{ar.msg.as_string() if ar.msg else None}" )
588-
if cli.verbosity > 0:
664+
if cli.verbosity > 1:
589665
click.echo( f"Response:\n{ar.rsp.as_string() if ar.rsp else None}" )
590666
sys.exit( status )
591667

slip39/invoice/communications_test.py

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@
1616

1717
log = logging.getLogger( __package__ )
1818

19-
# If we find a key, lets use it. Otherwise, we'll just use the pre-defined pre-signed email.Message
20-
19+
# If we find a DKIM key, lets use it. Otherwise, we'll just use the pre-defined pre-signed email.Message
2120
dkim_keys = list( Path( __file__ ).resolve().parent.parent.parent.glob( 'licensing.dominionrnd.com.*.key' ))
2221
dkim_key = None
2322
dkim_msg = None
@@ -34,6 +33,7 @@
3433
3534
3635
Subject: Hello, world!
36+
Content-Type: multipart/alternative; boundary================1903566236404015660==
3737
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=licensing.dominionrnd.com; [email protected]; q=dns/txt; s=20221230; t=1673405994; h=from : to;\
3838
bh=RkF6KP4Q94MVDBEv7pluaWdzw0z0GNQxK72rU02XNcE=; b=Tao30CJGcqyX86f37pSrSFLSDvA8VkzQW0jiMf+aFg5D99LsUYmUZxSgnDhW2ZEzjwu6bzjkEEyvSEv8LxfDUW+AZZG3enbq/mnnUZw3PXp4l\
3939
MaZGN9whvTIUy4/QUlMGKuf+7Vzi+8eKKjh4CWKN/UEyX6YoU7V5eyjTTA7q1jIjEl8jiM4LXYEFQ9LaKUmqqmRh2OkxBVf1QG+fEYTYUed+oS05m/d1SyVLjxv8ldeXT/mGgm1CrGk1qfRTzfcksX4qNAluTfJTa\
@@ -52,22 +52,26 @@
5252
5353
<em>Testing 123</em>
5454
--===============1903566236404015660==--
55+
5556
""" )
5657

5758
log.warning( f"Using DKIM: {dkim_selector}: {dkim_key}" )
5859

5960

6061
def test_communications_matchaddr():
61-
assert matchaddr( "abc+def@xyz", mailbox="abc", domain="xyz" ).groups() == ("abc", "def", "xyz")
62-
assert matchaddr( "abc+def@xyz", domain="xYz" ).groups() == ("abc", "def", "xyz")
63-
assert matchaddr( "abc+def@xyz", mailbox="Abc" ).groups() == ("abc", "def", "xyz")
64-
assert matchaddr( "abc+def@xyz", ).groups() == ("abc", "def", "xyz")
65-
assert matchaddr( "abc+def@xyz", ).group( 3 ) == "xyz"
62+
assert matchaddr( "abc+def@xyz", mailbox="abc", domain="xyz" ) == ("abc", "def", "xyz")
63+
assert matchaddr( "abc+def@xyz", domain="xYz" ) == ("abc", "def", "xyz")
64+
assert matchaddr( "abc+def@xyz", mailbox="Abc" ) == ("abc", "def", "xyz")
65+
assert matchaddr( "abc+def@xyz", ) == ("abc", "def", "xyz")
66+
assert matchaddr( "abc+def@xyz", "a*c","*f","x?z" ) == ("abc", "def", "xyz")
67+
assert matchaddr( "abc+def@xyz", "b*c","*f","x?z" ) is None
68+
assert matchaddr( "abc+def@xyz", "a*c","*f","x?" ) is None
69+
assert matchaddr( "abc+def@xyz", )[2] == "xyz"
6670
assert matchaddr( "abc+def@xyz", mailbox="xxx" ) is None
6771

6872

6973
def test_communications_dkim():
70-
msg = dkim_message(
74+
msg = dkim_msg if not dkim_key else dkim_message(
7175
sender_email = SMTP_FROM, # Message From: specifies claimed sender
7276
to_email = SMTP_TO, # See https://dkimvalidator.com to test!
7377
reply_to_email = "[email protected]",
@@ -77,7 +81,7 @@ def test_communications_dkim():
7781
dkim_private_key_path = dkim_key,
7882
dkim_selector = dkim_selector,
7983
headers = ['From', 'To'],
80-
) if dkim_key else dkim_msg
84+
)
8185

8286
log.info( f"DKIM Message: {msg}" )
8387

@@ -108,27 +112,31 @@ def test_communications_dkim():
108112
# recipient of the response. I guess, to avoid using "bounces" to send SPAM email. Therefore,
109113
# the auto-responder must be programmed to use the Reply-To address -- something that eg. Gmail
110114
# cannot be programmed to do.
111-
send_message(
112-
msg,
113-
#from_addr = SMTP_FROM, # Envelope MAIL FROM: specifies actual sender
114-
#to_addrs = [ SMTP_TO ], # Will be the same as message To: (should default)
115-
#relay = ['mail2.kundert.ca'], # 'localhost', # use eg. ssh -fNL 0.0.0.0:25:linda.mx.cloudflare.net:25 [email protected]
116-
#port = 25, # 465 --> SSL, 587 --> TLS (default),
117-
#usessl = False, starttls = False, verifycert = False, # to mail.kundert.ca; no TLS
118-
#usessl = False, starttls = True, verifycert = False, # default
119-
)
120-
# This may fail (eg. if you have no access to networking), so we don't check.
115+
try:
116+
send_message(
117+
msg,
118+
#from_addr = SMTP_FROM, # Envelope MAIL FROM: specifies actual sender
119+
#to_addrs = [ SMTP_TO ], # Will be the same as message To: (should default)
120+
relay = ['mail2.kundert.ca'], # 'localhost', # use eg. ssh -fNL 0.0.0.0:25:linda.mx.cloudflare.net:25 [email protected]
121+
#port = 25, # 465 --> SSL, 587 --> TLS (default),
122+
#usessl = False, starttls = False, verifycert = False, # to mail.kundert.ca; no TLS
123+
#usessl = False, starttls = True, verifycert = False, # default
124+
)
125+
except Exception as exc:
126+
# This may fail (eg. if you have no access to networking), so we don't check.
127+
log.warning( f"Failed to send DKIM-validated email to {SMTP_TO}: {exc}" )
128+
pass
121129

122130

123131
def test_communications_autoresponder( monkeypatch ):
124-
"""The Postfix-compatible auto-responder takes an email from stdin, and auto-forwards it (via a
125-
relay; normally the same Postfix installation that it is running within).
132+
"""The Postfix-compatible auto-responder takes an email.Message from stdin, and auto-forwards it
133+
(via a relay; normally the same Postfix installation that it is running within).
126134
127135
Let's shuttle a simple message through the AutoResponder, and fire up an SMTP daemon to receive
128136
the auto-forwarded message(s).
129137
130138
"""
131-
msg = dkim_message(
139+
msg = dkim_msg if not dkim_key else dkim_message(
132140
sender_email = SMTP_FROM, # Message From: specifies claimed sender
133141
to_email = SMTP_TO, # See https://dkimvalidator.com to test!
134142
reply_to_email = "[email protected]",
@@ -138,13 +146,13 @@ def test_communications_autoresponder( monkeypatch ):
138146
dkim_private_key_path = dkim_key,
139147
dkim_selector = dkim_selector,
140148
headers = ['From', 'To'],
141-
) if dkim_key else dkim_msg
149+
)
142150

143151
envelopes = []
144152

145153
class PrintingHandler:
146154
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
147-
if matchaddr( address ).group( 3 ) not in ('dominionrnd.com', 'kundert.ca'):
155+
if matchaddr( address )[2] not in ('dominionrnd.com', 'kundert.ca'):
148156
return f'550 not relaying to {address}'
149157
envelope.rcpt_tos.append(address)
150158
return '250 OK'
@@ -190,7 +198,7 @@ async def handle_DATA(self, server, session, envelope):
190198

191199
monkeypatch.setattr( 'sys.stdin', StringIO( msg.as_string() ))
192200
ar = AutoResponder(
193-
address = SMTP_TO,
201+
address = SMTP_FROM,
194202
server = controller.hostname,
195203
port = controller.port,
196204
reinject = False,
@@ -217,7 +225,7 @@ async def handle_DATA(self, server, session, envelope):
217225
'--port', controller.port,
218226
#'--no-reinject',
219227
'--reinject', "echo",
220-
'licensing@dominionrnd.com',
228+
'*@*licensing.dominionrnd.com', # allow any sender mailbox, any ...licensing subdomain of dominionrnd.com
221229
from_addr,
222230
*to_addrs
223231
] ))

0 commit comments

Comments
 (0)