2121import ssl
2222import sys
2323
24+ from fnmatch import fnmatch
2425from subprocess import Popen , PIPE
2526
2627import 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
80103def 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
436459class 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
0 commit comments