1414# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
1515# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
1616#
17+ import json
1718import logging
1819import re
1920import smtplib
2526import click
2627import dkim
2728
28- from tabulate import tabulate
29- from email import utils , message_from_file
30- from email .mime import multipart , text
3129from crypto_licensing .licensing import doh
30+ from email import utils , message_from_file , message_from_string
31+ from email .mime import multipart , text
32+ from tabulate import tabulate
3233
3334from ..util import is_listlike , commas , uniq , log_cfg , log_level
3435
@@ -115,8 +116,6 @@ def dkim_message(
115116
116117 if headers is None :
117118 headers = ["From" , "To" , "Subject" ]
118- if signature_algorithm is None :
119- signature_algorithm = "rsa-sha256" # "ed25519-sha256" not well supported, yet.
120119
121120 sender_domain = matchaddr ( sender_email ).group ( 3 )
122121
@@ -151,6 +150,10 @@ def dkim_message(
151150 # needs to be encoded from strings to bytes.
152151 with open (dkim_private_key_path ) as fh :
153152 dkim_private_key = fh .read ()
153+ if signature_algorithm is None :
154+ # "ed25519-sha256" not well supported, yet. But, if the key is short, that's probably what it is.
155+ signature_algorithm = "rsa-sha256" if len ( dkim_private_key ) > 48 else 'ed25519-sha256'
156+
154157 sig = dkim .sign (
155158 message = msg_data ,
156159 selector = str (dkim_selector ).encode (),
@@ -165,24 +168,27 @@ def dkim_message(
165168 # b'DKIM-Signature: v=1; i=@lic...\r\n s=... b=Fp2...6H\r\n 5//6o...Ag=='
166169 # ^^^^^ ^^^^^
167170 #
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.
171+ # contains a bunch of unnecessary whitespace, especially within the b: and bh: base-64 data.
172+ # However, this whitespace is ignored by the standard email.Message parser. Tidy it up .
170173 #
171174 pre ,sig_dirty = sig .decode ( 'utf-8' ).split ( ':' , 1 )
172- log .info ( f"DKIM signed: { sig_dirty !r} " )
175+ log .info ( f"DKIM signed: { sig_dirty . strip () !r} " )
173176 assert pre .lower () == "dkim-signature"
174177
175- # This seems to corrupt the signature, unexpectedly...
176- #sig_kvs = sig_dirty.split( ';' )
177- #sig_k_v = list(
178- # (k.strip(), ''.join(v.split())) # eliminates internal v whitespace
179- # for k,v in ( kv.split( '=', 1 ) for kv in sig_kvs )
180- #)
181- #sig_clean = '; '.join( f"{k}={v}" for k,v in sig_k_v )
182- #log.info( f"DKIM clean: {sig_clean!r}" )
183-
178+ # This seemed to corrupt the signature, unexpectedly... Ah, because we removed internal
179+ # whitespace from with the '; h=from : to', which is part of the signature. Only clean b/bh.
180+ sig_kvs = sig_dirty .split ( ';' )
181+ sig_k_v = dict (
182+ (k .strip (), v .strip ())
183+ for k ,v in ( kv .split ( '=' , 1 ) for kv in sig_kvs )
184+ )
185+ for k in ('b' , 'bh' ):
186+ if k in sig_k_v : # eliminates internal whitespace
187+ sig_k_v [k ] = '' .join ( sig_k_v [k ].split () )
188+ sig_clean = '; ' .join ( f"{ k } ={ v } " for k ,v in sig_k_v .items () )
189+ log .info ( f"DKIM clean: { sig_clean !r} " )
184190 # add the dkim signature to the email message headers.
185- msg ["DKIM-Signature" ] = sig_dirty . strip ()
191+ msg ["DKIM-Signature" ] = sig_clean
186192
187193 return msg
188194
@@ -346,17 +352,33 @@ def __init__( self, reinject=None ):
346352 if reinject in (None , True ):
347353 reinject = [ '/usr/sbin/sendmail' , '-G' , '-i' , '-f' ]
348354 if is_listlike ( reinject ):
349- self .reinject = list ( map ( str , reinject ))
355+ self .reinject = list ( map ( str , reinject )) # [...]: command, eg. 'sendmail', ...
350356 elif not reinject :
351- self .reinject = lambda * args : None # False, ''
357+ self .reinject = lambda * args : None # False, '': A NO-OP -- do not re-inject
352358 else :
353- self .reinject = reinject # str, callable
354- log . info ( f"Mail reinjection: { self .reinject !r } " )
359+ self .reinject = reinject # str, callable: a Shell command, or a function
360+ self .reset ( )
355361
356- def respond ( self , from_addr , * to_addrs ):
357- msg = self .message ()
362+ def reset ( self ):
363+ self .msg = None # The incoming original email.Message
364+ self .msg_peers = (None ,[]) # and its (mail_from, [rcpt_to, ...])
365+ self .rsp = None # The intended response email.Message
366+ self .rsp_peers = (None ,[]) # and its (mail_from, [rcpt_to, ...])
367+
368+ def __call__ ( self , from_addr , * to_addrs ):
369+ """Respond to an incoming email.Message according to the filter. Returns an appropriate Filter exit
370+ status code (see above).
371+
372+ The base class simply re-injects the message, and does nothing else (does not even try to
373+ generate the self.rsp from self.response( msg ), which remains None.)
374+
375+ """
376+ self .reset ()
377+ self .msg = self .message ()
378+ self .msg_peers = (from_addr , to_addrs )
358379 try :
359- # Allow a command (list), a shell command or a function for reinject
380+ # Allow a command (list), a shell command or a function for reinject, logging
381+ # the results of the re-injection command/function.
360382 err = None
361383 if is_listlike ( self .reinject ):
362384 with Popen (
@@ -365,7 +387,7 @@ def respond( self, from_addr, *to_addrs ):
365387 stdout = PIPE ,
366388 stderr = PIPE
367389 ) as process :
368- out , err = process .communicate ( msg .as_bytes () )
390+ out , err = process .communicate ( self . msg .as_bytes () )
369391 out = out .decode ( 'UTF-8' )
370392 err = err .decode ( 'UTF-8' )
371393 elif isinstance ( self .reinject , str ):
@@ -376,29 +398,39 @@ def respond( self, from_addr, *to_addrs ):
376398 stdout = PIPE ,
377399 stderr = PIPE
378400 ) as process :
379- out , err = process .communicate ( msg .as_bytes () )
401+ out , err = process .communicate ( self . msg .as_bytes () )
380402 out = out .decode ( 'UTF-8' )
381403 err = err .decode ( 'UTF-8' )
382404 else :
383405 out = self .reinject ( from_addr , * to_addrs )
384406 log .info ( f"Results of reinjection: stdout: { out } , stderr: { err } " )
385407 except Exception as exc :
408+ # Something went wrong attempting re-injection
386409 log .warning ( f"Re-injecting message From: { from_addr } , To: { commas ( to_addrs )} via sendmail failed: { exc } " )
387- raise
388- return msg
410+ return 75 # tempfail
411+ return 0
389412
390413 def message ( self ):
391- """Return (a possibly altered) email.Message as the auto-response . By default, an filter
392- receives its email.Message from stdin, unaltered.
414+ """Obtain and return the email.Message we are to process . By default, an filter receives its
415+ email.Message from stdin, unaltered.
393416
394417 """
395- msg = message_from_file ( sys .stdin )
396- return msg
418+ return message_from_file ( sys .stdin )
397419
398- def response ( self , msg ):
399- """Prepare the response message. Normally, it is at least just a different message."""
400- msg
401- return msg
420+ def response ( self , msg , new_id = None ):
421+ """Prepare the auto-responder message. Copies the incoming email.Message, to avoid altering
422+ it. Optionally, give it a different Message-ID.
423+
424+ """
425+ rsp = message_from_string ( msg .as_string () )
426+ if new_id :
427+ domain = None
428+ # Prefer the sender field per RFC 2822:3.6.2.
429+ sender_m = matchaddr ( rsp ['Sender' ] if 'Sender' in rsp else rsp ['From' ] )
430+ if sender_m :
431+ domain = sender_m .group ( 3 )
432+ rsp ['Message-ID' ] = utils .make_msgid ( domain = domain )
433+ return rsp
402434
403435
404436class AutoResponder ( PostQueue ):
@@ -415,25 +447,33 @@ def __init__( self, *args, address=None, server=None, port=None, **kwds ):
415447
416448 log .info ( f"autoresponding to DKIM-signed emails To: { self .address } @{ self .domain } " )
417449
418- def respond ( self , from_addr , * to_addrs ):
419- """Decide if we should auto-respond, and do so."""
450+ def __call__ ( self , from_addr , * to_addrs ):
451+ """Decide if we should auto-respond, and do so. Return the email.Message, and an appropriate
452+ Postfix exit code.
453+
454+ """
455+
456+ status = super ().__call__ ( from_addr , * to_addrs )
457+ if status :
458+ return status
420459
421- msg = super ().respond ( from_addr , * to_addrs )
422- log .info ( f"Filtered From: { msg ['from' ]} , To: { msg ['to' ]} "
460+ log .info ( f"Filtered From: { self .msg ['From' ]} , To: { self .msg ['To' ]} "
423461 f" originally from { from_addr } to { len (to_addrs )} recipients: { commas ( to_addrs , final = 'and' )} " )
424462
425- if 'to' not in msg or not matchaddr ( msg ['to' ], mailbox = self .mailbox , domain = self .domain ):
426- log .warning ( f"Message From: { msg ['from' ]} , To: { msg ['to' ]} expected To: { self .address } ; not auto-responding" )
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" )
427466 return 0
428- if 'dkim-signature' not in msg :
429- log .warning ( f"Message From: { msg ['from ' ]} , To: { msg ['to ' ]} is not DKIM signed; not auto-responding" )
467+ if 'dkim-signature' not in self . msg :
468+ log .warning ( f"Message From: { self . msg ['From ' ]} , To: { self . msg ['To ' ]} is not DKIM signed; not auto-responding" )
430469 return 0
431- if not dkim .verify ( msg .as_bytes () ):
432- log .warning ( f"Message From: { msg ['from ' ]} , To: { msg ['to ' ]} DKIM signature fails; not auto-responding " )
470+ if not dkim .verify ( self . msg .as_bytes () ):
471+ log .warning ( f"Message From: { self . msg ['From ' ]} , To: { self . msg ['To ' ]} DKIM signature fails; not auto-responding " )
433472 return 0
434473
435- # Normalize, uniqueify and filter the addresses (discarding invalids). Avoid sending it to
436- # the designated self.address, as this would set up a mail loop.
474+ # 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.
437477 to_addrs_filt = [
438478 fa
439479 for fa in uniq ( filter ( None , (
@@ -443,36 +483,40 @@ def respond( self, from_addr, *to_addrs ):
443483 if fa != self .address
444484 ]
445485
446- rsp = self . response ( msg )
447- # Also use the Reply-To: address, if supplied
448- if 'reply-to' in rsp :
449- _ ,reply_to = utils .parseaddr ( rsp ['reply-to' ] )
486+ # Get the outgoing message; also use the Reply-To: address, if supplied
487+ self . rsp = self . response ( self . msg )
488+ if 'reply-to' in self . rsp :
489+ _ ,reply_to = utils .parseaddr ( self . rsp ['reply-to' ] )
450490 if reply_to not in to_addrs_filt :
451491 to_addrs_filt .append ( reply_to )
492+ self .rsp_peers = (from_addr , tuple ( to_addrs_filt ))
452493
453- log .info ( f"Response From: { rsp ['from ' ]} , To: { rsp ['to ' ]} "
494+ log .info ( f"Response From: { self . rsp ['From ' ]} , To: { self . rsp ['To ' ]} "
454495 f" autoresponding from { from_addr } to { len (to_addrs_filt )} recipients: { commas ( to_addrs_filt , final = 'and' )} "
455- + ( f" (was { len (to_addrs )} : { commas ( to_addrs , final = 'and' )} )" if to_addrs != to_addrs_filt else " " ))
496+ + ( "" if set ( to_addrs ) == set ( to_addrs_filt ) else f" (was { len (to_addrs )} : { commas ( to_addrs , final = 'and' )} )" ))
456497
457- # Now, send the same message to all the supplied Reply-To, and Cc/Bcc address (already in
458- # responder. to_addrs). If it is DKIM signed, since we're not adjusting the message -- just
459- # send w/ new RCPT TO: envelope addresses. We'll use the same from_addr address (it must be
460- # from our domain, or we wouldn't be processing it .
498+ # Now, send the same message to all the supplied Reply-To, and Cc/Bcc addresses (which were
499+ # already in to_addrs). If it is DKIM signed, it will remain so, since we're not adjusting
500+ # the message -- just send w/ new RCPT TO: envelope addresses. We'll use the same from_addr
501+ # address .
461502 try :
462503 send_message (
463- rsp ,
504+ self . rsp ,
464505 from_addr = from_addr ,
465506 to_addrs = to_addrs_filt ,
466507 relay = self .relay ,
467508 port = self .port ,
468509 )
469510 except Exception as exc :
470- log .warning ( f"Message From: { rsp ['from ' ]} , To: { rsp ['to ' ]} autoresponder SMTP send failed: { exc } " )
511+ log .warning ( f"Message From: { self . rsp ['From ' ]} , To: { self . rsp ['To ' ]} autoresponder SMTP send failed: { exc } " )
471512 return 75 # tempfail
472513
473514 return 0
474515
475516
517+ #
518+ # Provide a CLI to access the AutoResponder
519+ #
476520@click .group ()
477521@click .option ('-v' , '--verbose' , count = True )
478522@click .option ('-q' , '--quiet' , count = True )
@@ -508,20 +552,42 @@ def autoresponder( address, from_addr, to_addrs, server, port, reinject ):
508552 - We won't autorespond to copies forwarded from other email addresses
509553
510554
511- Configure Postfix system as per: https://github.com/innovara/autoreply, except create
555+ Configure Postfix system as per: https://github.com/innovara/autoreply, eg.:
512556
513557 # autoresponder pipe
514558 autoreply unix - n n - - pipe
515559 flags= user=autoreply null_sender=
516- argv=python -m slip39.email autoresponder [email protected] ${sender} ${recipient} 560+ argv=python3 -m slip39.invoice.communication autoresponder [email protected] ${sender} ${recipient} 517561
518562 """
519- AutoResponder (
563+ ar = AutoResponder (
520564 address = address ,
521565 server = server ,
522566 port = port ,
523567 reinject = reinject ,
524- ).respond ( from_addr , * to_addrs )
568+ )
569+ status = ar ( from_addr , * to_addrs )
570+ if cli .json :
571+ click .echo ( json .dumps ( dict (
572+ dict (
573+ dict (
574+ Original = ar .msg .as_string () if ar .msg else None ,
575+ ) if cli .verbosity > 1 else dict (),
576+ Response = ar .rsp .as_string () if ar .rsp else None ,
577+ ) if cli .verbosity > 0 else dict (),
578+ status = status ,
579+ MAIL_FROM = ar .rsp_peers [0 ],
580+ RCPT_TOs = ar .rsp_peers [1 ],
581+ ), indent = 4 ))
582+ else :
583+ click .echo ( f"status: { status } " )
584+ click .echo ( f"MAIL FROM: { ar .rsp_peers [0 ]} " )
585+ click .echo ( f"RCPT TOs: { commas ( ar .rsp_peers [1 ] )} " )
586+ if cli .verbosity > 1 :
587+ click .echo ( f"Original:\n { ar .msg .as_string () if ar .msg else None } " )
588+ if cli .verbosity > 0 :
589+ click .echo ( f"Response:\n { ar .rsp .as_string () if ar .rsp else None } " )
590+ sys .exit ( status )
525591
526592
527593cli .add_command ( autoresponder )
0 commit comments