3535
3636__author__ = "Perry Kundert"
373738- __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
4141log = 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+
6079def 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
479530if __name__ == "__main__" :
0 commit comments