Skip to content

Commit e84c0fb

Browse files
committed
Test Postfix autoresponder w/ SMTP daemon
1 parent 69e6259 commit e84c0fb

File tree

2 files changed

+144
-56
lines changed

2 files changed

+144
-56
lines changed
Lines changed: 87 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535

3636
__author__ = "Perry Kundert"
3737
__email__ = "[email protected]"
38-
__copyright__ = "Copyright (c) 2022 Dominion Research & Development Corp."
38+
__copyright__ = "Copyright (c) 2023 Dominion Research & Development Corp."
3939
__license__ = "Dual License: GPLv3 (or later) and Commercial (see LICENSE)"
4040

4141
log = logging.getLogger( 'email' )
@@ -57,6 +57,25 @@ def mx_records( domain, timeout=None ):
5757
yield mx
5858

5959

60+
def matchaddr( address, mailbox=None, domain=None ):
61+
"""The supplied email address begins with "<mailbox>", optionally followed by a "+<extension>", and
62+
then ends with "@<domain>". If so, return the re.match w/ the 3 groups. If either 'mailbox' or
63+
'domain' is falsey, any will be allowed.
64+
65+
Does not properly respect email addresses with quoting, eg. 'abc"123@456"@domain.com' because,
66+
quite frankly, I don't want to and that's just "Little Bobby Tables" (https://xkcd.com/327/)
67+
level asking for trouble...
68+
69+
Simple <mailbox>[+<extension>]@<domain>, please.
70+
71+
"""
72+
return re.match(
73+
rf"(^{mailbox if mailbox else '[^@+]*'})(?:\+([^@]+))?@({domain if domain else '.*'})",
74+
utils.parseaddr( address )[1],
75+
re.IGNORECASE
76+
)
77+
78+
6079
def dkim_message(
6180
sender_email, # The message From:; may be empty (but not for DKIM). For display by the client
6281
subject,
@@ -99,9 +118,9 @@ def dkim_message(
99118
if signature_algorithm is None:
100119
signature_algorithm = "rsa-sha256" # "ed25519-sha256" not well supported, yet.
101120

102-
sender_domain = sender_email.split("@")[-1]
121+
sender_domain = matchaddr( sender_email ).group( 3 )
103122

104-
msg = multipart.MIMEMultipart("alternative")
123+
msg = multipart.MIMEMultipart("alternative")
105124
msg.attach(text.MIMEText(message_text, "plain"))
106125
if message_html:
107126
msg.attach(text.MIMEText(message_html, "html"))
@@ -117,7 +136,7 @@ def dkim_message(
117136
if reply_to_email:
118137
# Autoresponders don't generally respect Reply-To (as recommended in RFC-3834)
119138
# https://www.rfc-editor.org/rfc/rfc3834#section-4.
120-
msg["Reply-To"] = reply_to_email
139+
msg["Reply-To"] = reply_to_email
121140
msg["Subject"] = subject
122141

123142
try:
@@ -146,7 +165,8 @@ def dkim_message(
146165
# b'DKIM-Signature: v=1; i=@lic...\r\n s=... b=Fp2...6H\r\n 5//6o...Ag=='
147166
# ^^^^^ ^^^^^
148167
#
149-
# contains a bunch of errant whitespace, especially within the b: and bh: base-64 data
168+
# contains a bunch of unnecessary whitespace, especially within the b: and bh: base-64
169+
# data. However, this whitespace is ignored by the standard email.Message parser.
150170
#
151171
pre,sig_dirty = sig.decode( 'utf-8' ).split( ':', 1 )
152172
log.info( f"DKIM signed: {sig_dirty!r}" )
@@ -162,9 +182,7 @@ def dkim_message(
162182
#log.info( f"DKIM clean: {sig_clean!r}" )
163183

164184
# add the dkim signature to the email message headers.
165-
# decode the signature back to string_type because later on
166-
# the call to msg.as_string() performs it's own bytes encoding...
167-
msg["DKIM-Signature"] = sig_dirty.strip() # sig_clean
185+
msg["DKIM-Signature"] = sig_dirty.strip()
168186

169187
return msg
170188

@@ -303,7 +321,7 @@ def getreply( self ):
303321
return msg
304322

305323

306-
class postqueue:
324+
class PostQueue:
307325
"""A postfix-compatible post-queue filter. See:
308326
https://codepoets.co.uk/2015/python-content_filter-for-postfix-rewriting-the-subject/
309327
@@ -322,22 +340,48 @@ class postqueue:
322340
323341
Postfix will pass all To:, Cc: and Bcc: recipients in to_addrs.
324342
325-
The reinject command is executed/called, and passed from_addr and *to_addrs.
343+
The reinject command is executed/called, and passed from_addr and *to_addrs; False disables.
326344
"""
327345
def __init__( self, reinject=None ):
328-
if reinject is None:
329-
reinject = [ '/usr/bin/sendmail', '-G', '-i', '-f' ]
330-
self.reinject = reinject
346+
if reinject in (None, True):
347+
reinject = [ '/usr/sbin/sendmail', '-G', '-i', '-f' ]
348+
if is_listlike( reinject ):
349+
self.reinject = list( map( str, reinject ))
350+
elif not reinject:
351+
self.reinject = lambda *args: None # False, ''
352+
else:
353+
self.reinject = reinject # str, callable
354+
log.info( f"Mail reinjection: {self.reinject!r}" )
331355

332356
def respond( self, from_addr, *to_addrs ):
333357
msg = self.message()
334358
try:
335-
# Allow a command (list), or a function for reinject
359+
# Allow a command (list), a shell command or a function for reinject
360+
err = None
336361
if is_listlike( self.reinject ):
337-
with Popen( self.reinject + [ from_addr] + to_addrs, stdin=PIPE ) as process:
338-
process.communicate( msg.as_bytes() )
362+
with Popen(
363+
self.reinject + [ from_addr] + list( to_addrs ),
364+
stdin = PIPE,
365+
stdout = PIPE,
366+
stderr = PIPE
367+
) as process:
368+
out, err = process.communicate( msg.as_bytes() )
369+
out = out.decode( 'UTF-8' )
370+
err = err.decode( 'UTF-8' )
371+
elif isinstance( self.reinject, str ):
372+
with Popen(
373+
f"{self.reinject} {from_addr} {' '.join( to_addrs )}",
374+
shell = True,
375+
stdin = PIPE,
376+
stdout = PIPE,
377+
stderr = PIPE
378+
) as process:
379+
out, err = process.communicate( msg.as_bytes() )
380+
out = out.decode( 'UTF-8' )
381+
err = err.decode( 'UTF-8' )
339382
else:
340-
self.reinject( from_addr, *to_addrs )
383+
out = self.reinject( from_addr, *to_addrs )
384+
log.info( f"Results of reinjection: stdout: {out}, stderr: {err}" )
341385
except Exception as exc:
342386
log.warning( f"Re-injecting message From: {from_addr}, To: {commas( to_addrs )} via sendmail failed: {exc}" )
343387
raise
@@ -357,22 +401,7 @@ def response( self, msg ):
357401
return msg
358402

359403

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 ):
404+
class AutoResponder( PostQueue ):
376405
def __init__( self, *args, address=None, server=None, port=None, **kwds ):
377406
m = matchaddr( address or '' )
378407
assert m, \
@@ -460,20 +489,42 @@ def cli( verbose, quiet, json ):
460489

461490

462491
@click.command()
463-
@click.argument( 'address' )
492+
@click.argument( 'address', nargs=1 )
493+
@click.argument( 'from_addr', nargs=1 )
494+
@click.argument( 'to_addrs', nargs=-1 )
464495
@click.option( '--server', default='localhost' )
465496
@click.option( '--port', default=25 )
466-
def respond( address, server, port ):
497+
@click.option( '--reinject', type=str, default=None, help="A custom command to reinject email, eg. via sendmail" )
498+
@click.option( '--no-reinject', 'reinject', flag_value='', help="Disable reinjection of filtered mail, eg. via sendmail" )
499+
def autoresponder( address, from_addr, to_addrs, server, port, reinject ):
467500
"""Run an auto-responder that replies to all incoming emails to the specified email address.
468501
502+
Will be invoked with a from_addr and 1 or more to_addrs.
503+
469504
- Must be DKIM signed, including the From: and To: addresses.
470505
- The RCPT TO: "envelope" address must match 'address':
471506
- We won't autorespond to copies of the email being delivered to other inboxes
472507
- The MAIL FROM: "envelope" address must match the From: address
473508
- We won't autorespond to copies forwarded from other email addresses
474509
510+
511+
Configure Postfix system as per: https://github.com/innovara/autoreply, except create
512+
513+
# autoresponder pipe
514+
autoreply unix - n n - - pipe
515+
flags= user=autoreply null_sender=
516+
argv=python -m slip39.email autoresponder [email protected] ${sender} ${recipient}
517+
475518
"""
476-
pass
519+
AutoResponder(
520+
address = address,
521+
server = server,
522+
port = port,
523+
reinject = reinject,
524+
).respond( from_addr, *to_addrs )
525+
526+
527+
cli.add_command( autoresponder )
477528

478529

479530
if __name__ == "__main__":
Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,33 @@
1-
from io import StringIO
1+
import logging
2+
import os
23

4+
from io import StringIO
35
from pathlib import Path
6+
from subprocess import Popen, PIPE
47

58
import dkim
69

710
from aiosmtpd.controller import Controller
811

9-
from .email import dkim_message, send_message, matchaddr, autoresponder
12+
from .communications import dkim_message, send_message, matchaddr, AutoResponder
1013
from ..defaults import SMTP_TO, SMTP_FROM
1114

15+
log = logging.getLogger( __package__ )
1216

1317
dkim_key = Path( __file__ ).resolve().parent.parent.parent / 'licensing.dominionrnd.com.20221230.key'
1418
dkim_selector = '20221230'
1519

1620

17-
def test_email_dkim():
21+
def test_communications_matchaddr():
22+
assert matchaddr( "abc+def@xyz", mailbox="abc", domain="xyz" ).groups() == ("abc", "def", "xyz")
23+
assert matchaddr( "abc+def@xyz", domain="xYz" ).groups() == ("abc", "def", "xyz")
24+
assert matchaddr( "abc+def@xyz", mailbox="Abc" ).groups() == ("abc", "def", "xyz")
25+
assert matchaddr( "abc+def@xyz", ).groups() == ("abc", "def", "xyz")
26+
assert matchaddr( "abc+def@xyz", ).group( 3 ) == "xyz"
27+
assert matchaddr( "abc+def@xyz", mailbox="xxx" ) is None
28+
29+
30+
def test_communications_dkim():
1831
msg = dkim_message(
1932
sender_email = SMTP_FROM, # Message From: specifies claimed sender
2033
to_email = SMTP_TO, # See https://dkimvalidator.com to test!
@@ -52,27 +65,19 @@ def test_email_dkim():
5265
msg,
5366
#from_addr = SMTP_FROM, # Envelope MAIL FROM: specifies actual sender
5467
#to_addrs = [ SMTP_TO ], # Will be the same as message To: (should default)
55-
#relay = ['mail2.kundert.ca'], # 'localhost', # use eg. ssh -fNL 0.0.0.0:25:linda.mx.cloudflare.net:25 [email protected]
68+
#relay = ['mail2.kundert.ca'], # 'localhost', # use eg. ssh -fNL 0.0.0.0:25:linda.mx.cloudflare.net:25 [email protected]
5669
#port = 25, # 465 --> SSL, 587 --> TLS (default),
5770
#usessl = False, starttls = False, verifycert = False, # to mail.kundert.ca; no TLS
5871
#usessl = False, starttls = True, verifycert = False, # default
5972
)
73+
# This may fail (eg. if you have no access to networking), so we don't check.
6074

6175

62-
def test_email_matchaddr():
63-
assert matchaddr( "abc+def@xyz", mailbox="abc", domain="xyz" ).groups() == ("abc", "def", "xyz")
64-
assert matchaddr( "abc+def@xyz", domain="xYz" ).groups() == ("abc", "def", "xyz")
65-
assert matchaddr( "abc+def@xyz", mailbox="Abc" ).groups() == ("abc", "def", "xyz")
66-
assert matchaddr( "abc+def@xyz", ).groups() == ("abc", "def", "xyz")
67-
assert matchaddr( "abc+def@xyz", ).group( 3 ) == "xyz"
68-
assert matchaddr( "abc+def@xyz", mailbox="xxx" ) is None
69-
70-
71-
def test_email_autoresponder( monkeypatch ):
72-
"""The Postfix-compatible autoresponder takes an email from stdin, and auto-forwards it (via a
76+
def test_communications_autoresponder( monkeypatch ):
77+
"""The Postfix-compatible auto-responder takes an email from stdin, and auto-forwards it (via a
7378
relay; normally the same Postfix installation that it is running within).
7479
75-
Let's shuttle a simple message through the autoresponder, and fire up an SMTP daemon to receive
80+
Let's shuttle a simple message through the AutoResponder, and fire up an SMTP daemon to receive
7681
the auto-forwarded message(s).
7782
7883
"""
@@ -87,8 +92,8 @@ def test_email_autoresponder( monkeypatch ):
8792
dkim_selector = dkim_selector,
8893
headers = ['From', 'To'],
8994
)
90-
9195
envelopes = []
96+
9297
class PrintingHandler:
9398
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
9499
if matchaddr( address ).group( 3 ) not in ('dominionrnd.com', 'kundert.ca'):
@@ -136,15 +141,47 @@ async def handle_DATA(self, server, session, envelope):
136141
to_addrs += map( str.strip, msg[cc].split( ',' ) )
137142

138143
monkeypatch.setattr( 'sys.stdin', StringIO( msg.as_string() ))
139-
ar = autoresponder(
144+
ar = AutoResponder(
140145
address = SMTP_TO,
141146
server = controller.hostname,
142147
port = controller.port,
143-
reinject = lambda *args, **kwds: None
148+
reinject = False,
144149
)
145150
ar.respond(
146151
from_addr, *to_addrs
147152
)
148-
153+
149154
assert len( envelopes ) == 2
150155
assert envelopes[-1].rcpt_tos == [ '[email protected]' ]
156+
157+
# Now, try the CLI version.
158+
here = Path( __file__ ).resolve().parent
159+
for execute in [
160+
[
161+
"python3", "-m", "slip39.invoice.communications",
162+
]
163+
164+
]:
165+
command = list( map( str, execute + [
166+
'-v',
167+
'autoresponder',
168+
'--server', controller.hostname,
169+
'--port', controller.port,
170+
#'--no-reinject',
171+
'--reinject', "echo",
172+
173+
from_addr,
174+
*to_addrs
175+
] ))
176+
log.info( f"Running filter: {' . '.join( command )}" )
177+
with Popen(
178+
command,
179+
stdin = PIPE,
180+
env = dict(
181+
os.environ,
182+
PYTHONPATH = f"{here.parent.parent}"
183+
)) as process:
184+
process.communicate( msg.as_bytes() )
185+
186+
assert len( envelopes ) == 3
187+
assert envelopes[-1].rcpt_tos == [ '[email protected]' ]

0 commit comments

Comments
 (0)