1-
1+ #! /usr/bin/env python3
22#
33# Python-slip39 -- Ethereum SLIP-39 Account Generation and Recovery
44#
1515# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
1616#
1717import logging
18+ import re
1819import smtplib
1920import ssl
21+ import sys
2022
21- from tabulate import tabulate
22- from email import utils
23- from email .mime import multipart , text
23+ from subprocess import Popen , PIPE
2424
25+ import click
2526import dkim
2627
28+ from tabulate import tabulate
29+ from email import utils , message_from_file
30+ from email .mime import multipart , text
2731from crypto_licensing .licensing import doh
2832
29- from ..util import is_listlike , commas
33+ from ..util import is_listlike , commas , uniq , log_cfg , log_level
3034
3135
3236__author__ = "Perry Kundert"
@@ -170,10 +174,10 @@ def send_message(
170174 from_addr = None , # Envelope MAIL FROM: (use msg['Sender'/'From'] if not specified)
171175 to_addrs = None , # Envelope RCTP TO: (use msg['To'/'CC'/'BCC'] if not specified)
172176 relay = None , # Eg. "localhost"; None --> lookup MX record
173- port = 587 , # Eg. 25 --> raw TCP/IP, 587 --> TLS, 465 --> SSL
174- starttls = True , # Upgrade SMTP connection w/ TLS
175- verifycert = False , # and verify SSL/TLS certs (not generally supported )
176- usessl = False , # Connect using SMTP_SSL
177+ port = None , # Eg. 25 --> raw TCP/IP, 587 --> TLS (default) , 465 --> SSL
178+ starttls = None , # Upgrade SMTP connection w/ TLS (default: True iff port == 587)
179+ usessl = None , # Connect using SMTP_SSL (default: True iff port == 465 )
180+ verifycert = None , # and verify SSL/TLS certs (not generally supported)
177181 username = None ,
178182 password = None ,
179183):
@@ -205,8 +209,8 @@ def send_message(
205209 # Now that we have a to_addrs, construct a mapping of (mx, ...) --> [addr, ...]. For each
206210 # to_addrs, lookup its destination's mx records; we'll append all to_addrs w/ the same mx's
207211 # (sorted by priority).
212+ relay_addrs = {}
208213 if relay is None :
209- relay_addrs = dict ()
210214 for to in to_addrs :
211215 relay_addrs .setdefault ( tuple ( mx_records ( to .split ( '@' , 1 )[1 ] )), [] ).append ( to )
212216 else :
@@ -215,7 +219,7 @@ def send_message(
215219 relay_addrs [tuple ( relay )] = to_addrs
216220 relay_max = max ( len ( r ) for r in relay_addrs .keys () )
217221 addrs_max = max ( len ( a ) for a in relay_addrs .values () )
218- log .info ( f "Relays, and their destination addresses\n " + tabulate (
222+ log .info ( "Relays, and their destination addresses\n " + tabulate (
219223 [
220224 list ( r ) + ([ None ] * (relay_max - len ( r ))) + list ( a )
221225 for r ,a in relay_addrs .items ()
@@ -224,6 +228,16 @@ def send_message(
224228 tablefmt = 'orgtbl'
225229 ))
226230
231+ # Default port and TLS/SSL if unspecified.
232+ if port is None :
233+ port = 587
234+ if starttls is None :
235+ starttls = True if port == 587 else False
236+ if usessl is None :
237+ usessl = True if port == 465 else False
238+ if verifycert is None :
239+ verifycert = False
240+
227241 try :
228242 # Python 3 libraries expect bytes.
229243 msg_data = msg .as_bytes ()
@@ -287,3 +301,180 @@ def getreply( self ):
287301 log .info ( f"{ relayed } of { len ( relay_addrs )} relays succeeded" )
288302
289303 return msg
304+
305+
306+ class postqueue :
307+ """A postfix-compatible post-queue filter. See:
308+ https://codepoets.co.uk/2015/python-content_filter-for-postfix-rewriting-the-subject/
309+
310+ Receives an email via stdin, and re-injects it into the mail system via /usr/sbin/sendmail,
311+ raising an exception if for any reason it is unable to do so (caller should trap and return
312+ an appropriate exit status compatible w/ postfix:
313+
314+ 0: success
315+ 69: bounce
316+ 75: tempfail
317+
318+ Creates and sends a response email via SMTP to a relay (default is the local SMTP server at
319+ localhost:25)
320+
321+ The msg, from_addr and to_addrs are retained in self.msg, etc.
322+
323+ Postfix will pass all To:, Cc: and Bcc: recipients in to_addrs.
324+
325+ The reinject command is executed/called, and passed from_addr and *to_addrs.
326+ """
327+ def __init__ ( self , reinject = None ):
328+ if reinject is None :
329+ reinject = [ '/usr/bin/sendmail' , '-G' , '-i' , '-f' ]
330+ self .reinject = reinject
331+
332+ def respond ( self , from_addr , * to_addrs ):
333+ msg = self .message ()
334+ try :
335+ # Allow a command (list), or a function for reinject
336+ if is_listlike ( self .reinject ):
337+ with Popen ( self .reinject + [ from_addr ] + to_addrs , stdin = PIPE ) as process :
338+ process .communicate ( msg .as_bytes () )
339+ else :
340+ self .reinject ( from_addr , * to_addrs )
341+ except Exception as exc :
342+ log .warning ( f"Re-injecting message From: { from_addr } , To: { commas ( to_addrs )} via sendmail failed: { exc } " )
343+ raise
344+ return msg
345+
346+ def message ( self ):
347+ """Return (a possibly altered) email.Message as the auto-response. By default, an filter
348+ receives its email.Message from stdin, unaltered.
349+
350+ """
351+ msg = message_from_file ( sys .stdin )
352+ return msg
353+
354+ def response ( self , msg ):
355+ """Prepare the response message. Normally, it is at least just a different message."""
356+ msg
357+ return msg
358+
359+
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 ):
376+ def __init__ ( self , * args , address = None , server = None , port = None , ** kwds ):
377+ m = matchaddr ( address or '' )
378+ assert m , \
379+ f"Must supply a valid email destination address to auto-respond to: { address } "
380+ self .address = address
381+ self .mailbox ,self .extension ,self .domain = m .groups ()
382+ self .relay = 'localhost' if server is None else server
383+ self .port = 25 if port is None else port
384+
385+ super ().__init__ ( * args , ** kwds )
386+
387+ log .info ( f"autoresponding to DKIM-signed emails To: { self .address } @{ self .domain } " )
388+
389+ def respond ( self , from_addr , * to_addrs ):
390+ """Decide if we should auto-respond, and do so."""
391+
392+ msg = super ().respond ( from_addr , * to_addrs )
393+ log .info ( f"Filtered From: { msg ['from' ]} , To: { msg ['to' ]} "
394+ f" originally from { from_addr } to { len (to_addrs )} recipients: { commas ( to_addrs , final = 'and' )} " )
395+
396+ if 'to' not in msg or not matchaddr ( msg ['to' ], mailbox = self .mailbox , domain = self .domain ):
397+ log .warning ( f"Message From: { msg ['from' ]} , To: { msg ['to' ]} expected To: { self .address } ; not auto-responding" )
398+ return 0
399+ if 'dkim-signature' not in msg :
400+ log .warning ( f"Message From: { msg ['from' ]} , To: { msg ['to' ]} is not DKIM signed; not auto-responding" )
401+ return 0
402+ if not dkim .verify ( msg .as_bytes () ):
403+ log .warning ( f"Message From: { msg ['from' ]} , To: { msg ['to' ]} DKIM signature fails; not auto-responding " )
404+ return 0
405+
406+ # Normalize, uniqueify and filter the addresses (discarding invalids). Avoid sending it to
407+ # the designated self.address, as this would set up a mail loop.
408+ to_addrs_filt = [
409+ fa
410+ for fa in uniq ( filter ( None , (
411+ utils .parseaddr ( a )[1 ]
412+ for a in to_addrs
413+ )))
414+ if fa != self .address
415+ ]
416+
417+ rsp = self .response ( msg )
418+ # Also use the Reply-To: address, if supplied
419+ if 'reply-to' in rsp :
420+ _ ,reply_to = utils .parseaddr ( rsp ['reply-to' ] )
421+ if reply_to not in to_addrs_filt :
422+ to_addrs_filt .append ( reply_to )
423+
424+ log .info ( f"Response From: { rsp ['from' ]} , To: { rsp ['to' ]} "
425+ f" autoresponding from { from_addr } to { len (to_addrs_filt )} recipients: { commas ( to_addrs_filt , final = 'and' )} "
426+ + ( f" (was { len (to_addrs )} : { commas ( to_addrs , final = 'and' )} )" if to_addrs != to_addrs_filt else "" ))
427+
428+ # Now, send the same message to all the supplied Reply-To, and Cc/Bcc address (already in
429+ # responder.to_addrs). If it is DKIM signed, since we're not adjusting the message -- just
430+ # send w/ new RCPT TO: envelope addresses. We'll use the same from_addr address (it must be
431+ # from our domain, or we wouldn't be processing it.
432+ try :
433+ send_message (
434+ rsp ,
435+ from_addr = from_addr ,
436+ to_addrs = to_addrs_filt ,
437+ relay = self .relay ,
438+ port = self .port ,
439+ )
440+ except Exception as exc :
441+ log .warning ( f"Message From: { rsp ['from' ]} , To: { rsp ['to' ]} autoresponder SMTP send failed: { exc } " )
442+ return 75 # tempfail
443+
444+ return 0
445+
446+
447+ @click .group ()
448+ @click .option ('-v' , '--verbose' , count = True )
449+ @click .option ('-q' , '--quiet' , count = True )
450+ @click .option ( '--json/--no-json' , default = True , help = "Output JSON (the default)" )
451+ def cli ( verbose , quiet , json ):
452+ cli .verbosity = verbose - quiet
453+ log_cfg ['level' ] = log_level ( cli .verbosity )
454+ logging .basicConfig ( ** log_cfg )
455+ if verbose or quiet :
456+ logging .getLogger ().setLevel ( log_cfg ['level' ] )
457+ cli .json = json
458+ cli .verbosity = 0 # noqa: E305
459+ cli .json = False
460+
461+
462+ @click .command ()
463+ @click .argument ( 'address' )
464+ @click .option ( '--server' , default = 'localhost' )
465+ @click .option ( '--port' , default = 25 )
466+ def respond ( address , server , port ):
467+ """Run an auto-responder that replies to all incoming emails to the specified email address.
468+
469+ - Must be DKIM signed, including the From: and To: addresses.
470+ - The RCPT TO: "envelope" address must match 'address':
471+ - We won't autorespond to copies of the email being delivered to other inboxes
472+ - The MAIL FROM: "envelope" address must match the From: address
473+ - We won't autorespond to copies forwarded from other email addresses
474+
475+ """
476+ pass
477+
478+
479+ if __name__ == "__main__" :
480+ cli ()
0 commit comments