#!/usr/bin/env python # checkID-0.6.py 8/16/05 David MacQuigg '''Check that an ID in the Registry of Public Email Senders authorizes sending mail from an address IP.''' # 0.6 Add checkSBL(), SPF method # 0.5 Add exceptions NoID, BadFQDN, NoRecord, NoRatings, ParseError # 0.4 Add actions 'TEMPFAIL', FILTER, and 'DISCARD' # 0.3 Returns ( action, SMTP_reply, headers ) import sys, DNS # standard Python modules import dnsRegRec # to get a TXT record from the Registry using DNS import parseRegRec # to parse a Registry record using re from SpammerFile import SpammerDict # local blacklist class NoID(Exception): pass class BadFQDN(Exception): pass from DNS import DNSError from dnsRegRec import Timeout, NoRecord # subclasses of DNSError class NoRatings(Exception): pass from parseRegRec import ParseError # Authentication methods available at this site: import spf # SPF1 from sourceforge.net/projects/pymilter - milter-0.8.2 ##import csv # from mipassoc.org/csv ##import sid # SenderID from Microsoft # Methods that require transfer of DATA: ##import dkim # from Yahoo/Cisco # Authentication methods currently supported, their preferred order, and the # maximum number of DNS queries allowed. Methods with the same order will be # run in the order specified by the sender. METHOD_LIST = {'QR1': (1,0), 'CSV': (2,4), 'SPF': (3,30), 'SID': (3,30) } # Domain Rating Services - Assign whatever scores you think are appropriate for # each of the ratings from these services. Acceptance or rejection will be # based on the min, max and weighted average of these scores. 'Wt' is the # weight. See open-mail.org/ratings for a complete list of service codes. TRUSTED_SERVICES = {'S1': {'A': 9, 'B': 7, 'C': 5, 'D': 0, 'Wt': 5}, 'M2': {'A': 9, 'B': 7, 'C': 5, 'D': 0, 'Wt': 5}, 'H1': {'A': 9, 'B': 7, 'C': 5, 'D': 0, 'Wt': 5}} ACTIONS = ('ACCEPT', # accept for delivery, no more testing 'REJECT', # reject 'FILTER', # send to spam filter, delivery uncertain 'CONTINUE', # run more authentication tests, if available 'TEMPFAIL', # temporary reject, try again later 'DISCARD' # reject with no reply ) VERBOSE = 0 # Set > 0 for more output to diagnostic and log files Logfile = Dfile = None # defaults for no output ## Logfile = open('log_file', 'w') # normal logging & statistics Dfile = open('diagnostics_file', 'w') # extra diagnostic output ## Dfile = sys.stdout RCVR = 'mxR.receiver.com' # Name of this MTA. ADMIN = 'joe@receiver.com' # Address to report setup problems. ##DNS.DiscoverNameServers() # from /etc/resolv.conf or Windows Registry ## # to DNS.defaults # Alternative manual setting: DNS.defaults['server'] = ['216.183.68.110', '216.183.68.111'] ### USAGE = ''' To check that a Declared ID authorizes the use of an IP address: >>> checkID('192.168.0.1', 'simple.tld') ('ACCEPT', (250, '', 'Sender OK'), [{'text': '192.168.0.1 simple.tld QR1 PASS ratings=S1:A,M2:A,H1:B', 'label': 'Authent:'}]) >>> checkID('192.168.0.64', 'simple.tld') ('REJECT', (550, '5.7.1', "Sending MTA '192.168.0.64' not authorized by 'simple.tld'"), [{'text': '192.168.0.64 simple.tld QR1 FAIL ratings=S1:A,M2:A,H1:B', 'label': 'Authent:'}]) If all you have is the HELO name, no Declared ID: >>> checkID('192.0.4.1', H='mx1.abilene.texas.rr.tld') ('FILTER', (250, '?.?.?', \ "Message will be sent to spam filter - don't depend on delivery.\\n\ Your domain 'rr.tld' has ratings 5.\\n\ See open-mail.org/rejects for help."), \ [{'text': '192.0.4.1 rr.tld QR1 PASS ratings=5', 'label': 'Authent:'}]) We can see that the Default ID covering this HELO name is 'rr.tld', and that name has an average spam rating of 5. If you don't even have a usable HELO name: >>> checkID('192.168.0.1') ('FILTER', (250, '?.?.?', \ "Message will be sent to spam filter. Do not depend on delivery.\\n\ Your IP block '192.168.0.0/22' has ratings 5.\\n\ See open-mail.org/rejects for help."), \ [{'text': '192.168.0.1 NO-ID block=192.168.0.0/22 ratings=5', \ 'label': 'Authent:'}]) The best we can do is determine the IP block assigned by a Regional Registry. All domains withing this block will share a common spam score. If you get a timeout error, just try again after a few minutes: ->>> checkID('192.0.4.1', H='mx1.abilene.texas.rr.tld') ('TEMPFAIL', (450, '', "Timeout getting Registry record 'mx1.abilene.texas.rr.tld.df.open-mail.org'.\\nTry again later."), []) Registry records specify the authentication methods offered by a sender. Here is a query on a domain offering SPF authentication: >>> checkID('69.55.226.139', 'wayforward.net', H='mx1.wayforward.net', S='tway@optsw.com') ('ACCEPT', (250, '', 'sender SPF verified'), \ [{'text': '69.55.226.139 wayforward.net SPF1 pass ratings=S1:A,M2:A,H1:B', \ 'label': 'Authent:'}, \ {'text': 'pass (mxR.receiver.com: domain of optsw.com designates 69.55.226.139 as permitted sender) client-ip=69.55.226.139; envelope-from=tway@optsw.com; helo=mx1.wayforward.net;', \ 'label': 'Received-SPF:'}]) Notice that the Declared ID='wayforward.net' is not the same as the identity verified by SPF, S='tway@optsw.com'. In general, when calling for an ID check on a domain whose authentication methods are unknown, it is best to provide all identities in the call, and let the Registry sort out which one to use. Notice also there is an extra header 'Received-SPF:' in addition to the 'Authent:' header which is returned for all methods. To repeat this message, call checkID() with no arguments. ''' def checkID( IP=None, ID=None, H=None, S=None, R=[], Hdrs=[], Body=None ): '''Check that ID authorizes sending email from a machine with address IP. H, S, and R are the rest of the envelope information, whatever is known at the time of this call. ID should be left None if there is no declared ID. H and S are the names from the HELO/EHLO and MAIL FROM commands. R is a list of addresses from the RCPT TO command. Returns ( action, SMTP_reply, headers ) action: 'ACCEPT', 'REJECT', 'FILTER', 'CONTINUE', 'TEMPFAIL', 'DISCARD' see http://www.milter.org/milter_api/api.html SMTP_reply = ( SMTP_code, Xcode, explanation ) SMTP_code: SMTP Reply Code per RFC-2821 Xcode: Enhanced Mail System Status Code per RFC-3463 headers = [header0, header1, ...] header0 = {'label': 'Authent:', 'text': } headerN = {'label': , 'text': } 'header0' is inserted just below the Received: header for the Border MTA, i.e. the very first header from this Administrative Domain. Additional headers are prepended in the order listed. Text strings have no line breaks. >>> checkID('192.0.2.1', 'reject.example.com') ('REJECT', (550, '5.7.1', "Sending MTA '192.0.2.1' not authorized by \ 'reject.example.com'"), []) >>> checkID('192.0.2.1', 'example.com') ('ACCEPT', (250, '', 'Sender OK'), \ [{'text': '192.0.2.1 example.com SPF1 PASS ratings=S1:A,M2:A,H1:B', \ 'label': 'Authent:'}]) >>> checkID('192.168.0.1', 'simple.tld') ('ACCEPT', (250, '', 'Sender OK'), \ [{'text': '192.168.0.1 simple.tld QR1 PASS ratings=S1:A,M2:A,H1:B', \ 'label': 'Authent:'}]) ''' if IP == None: print USAGE; return ## Stub for local testing with no DNS queries: if IP == '192.0.2.1': return local_test(IP, ID) ## Quick pre-screen for currently-spamming IPs: ### if checkSBL(IP): expln = "IP '%s' is on Spamhaus BlackList" % IP return ('REJECT', (550, '5.7.1', expln), []) IDstatus = None # Initial assumption ## Process a Registry record assuming a valid Declared ID: try: if ID == None: raise NoID, "ID not declared." validate_FQDN( ID ) record = dnsRegRec.getTXT( ID + '.id.open-mail.org') parsedrec = parseRegRec.parse1( record ) svclist = parseRegRec.parse2(parsedrec['svc']) ratings, score, mins, maxs = get_ratings( svclist ) IDstatus = 'declared' except NoID, expln: pass # continue with search for a Default ID except BadFQDN, expln: exp = str(expln) return ('REJECT', (550, '5.7.1', exp), []) except NoRecord, expln: exp = str(expln) + ("\nDeclared ID with no Registry record!" + "\nSee open-mail.org/rejects." ) return ('REJECT', (550, '5.7.1', exp), []) except NoRatings: expln = ("No trusted ratings for '%s'" % ID + "\nDelivery uncertain." ) SMTP_reply = (250, '?.?.?', expln) ### codes ? headers = [{'label': 'Authent:', \ 'text': '%s %s UNVERIFIED' % (IP, ID)}] return ('FILTER', SMTP_reply, headers) except Timeout, expln: exp = str(expln) + "\nTry again later." return ('TEMPFAIL', (450, '', exp), []) except (DNSError, ParseError), expln: # Nameservers not found. Bad record syntax, etc. exp = str(expln) + "\nNotify mailsystem admin." ALERT(IP, ID, exp) # Send an email to the admin. if Logfile: print >> Logfile, ID + exp return ('TEMPFAIL', (450, '', exp), []) ## Attempt to get a Default ID covering the HELO name. if IDstatus == None: # i.e. no Declared Identity print >> Dfile, "Attempting to get a Default ID covering the HELO name." ### try: if H == None: raise NoRecord validate_FQDN( H ) if H == 'mx1.example.com': # stub for local testing record = "ID=example.com score=5 ip4=192.168.0.1/22" else: record = dnsRegRec.getTXT( H + '.df.open-mail.org') parsedrec = parseRegRec.parse1(record) ID = parsedrec['ID'] ratings = parsedrec['score'] # a single digit mins = maxs = int(ratings) # convert to an integer score = float(mins) IDstatus = 'default' print >> Dfile, ID, ratings, score, IDstatus except BadFQDN, expln: exp = str(expln) return ('REJECT', (550, '5.7.1', exp), []) except NoRecord, expln: exp1 = str(expln) # continue with the IP block search except Timeout, expln: exp = str(expln) + "\nTry again later." return ('TEMPFAIL', (450, '', exp), []) except (DNSError, ParseError), expln: # Nameservers not found. Bad record syntax, etc. exp = str(expln) + "\nNotify mailsystem admin." ALERT(IP, ID, exp) # Send an email to the admin. if Logfile: print >> Logfile, ID + exp return ('TEMPFAIL', (450, '', exp), []) ## Attempt to get a Default ID covering the IP address. if IDstatus == None: print >> Dfile, "Attempting to get a default ID covering the IP address." try: IPR = reverse_IP(IP) if IP == '192.0.2.2': # stub for local testing record = "ID=192.0.2.0/28 score=5" else: record = dnsRegRec.getTXT( IPR + '.ip.open-mail.org') parsedrec = parseRegRec.parse1(record) ID = parsedrec['ID'] ratings = parsedrec['score'] # a single digit mins = maxs = int(ratings) # convert to an integer score = float(mins) IDstatus = 'IPonly' print >> Dfile, ID, ratings, score, IDstatus except NoRecord, expln: exp2 = exp1 + '\n' + str(expln) exp3 = exp2 + "\nDelivey uncertain." SMTP_reply = (250, '', exp3) headers = [{'label': 'Authent:', \ 'text': '%s %s UNVERIFIED (%s)' % (IP, ID, exp2)}] return ('CONTINUE', SMTP_reply, headers) ### except Timeout, expln: exp = str(expln) + "\nTry again later." return ('TEMPFAIL', (450, '', exp), []) except (DNSError, ParseError), expln: # Nameservers not found. Bad record syntax, etc. exp = str(expln) + "\nNotify mailsystem admin." ALERT(IP, ID, exp) # Send an email to the admin. if Logfile: print >> Logfile, ID + exp return ('TEMPFAIL', (450, '', exp), []) print >> Dfile, "Continue processing. We now have an ID and its ratings." ## ## DISCARD with no reply: ### tarpits are controversial !!! ## if ID in SpammerDict: ## return ('DISCARD', None, []) ## Quick REJECT if ratings are too low. 80% of incoming is trash. if score < 3.0 or mins < 1: expln = ( "Sorry, we accept mail only from reputable domains.\n" + "Your domain '%s' has ratings %s.\n" % (ID, ratings) + "See open-mail.org/rejects for help." ) return ('REJECT', (550, '5.7.1', expln), []) ## Quick ACCEPT pending authentication. 15% of incoming is from A-rated # domains. The remaining 5% will be filtered and sorted by spam score. # if mins > 4 and maxs > 8: RATING = 'HIGH' else: RATING = 'LOW' ## Find the Authentication Methods to be used on this ID: # if IDstatus == 'IPonly': # No authentication necessary. action = 'FILTER' expln = ( "Message will be sent to spam filter. Do not depend on delivery.\n" + "Your IP block '%s' has ratings %s.\n" % (ID, ratings) + "See open-mail.org/rejects for help." ) SMTP_reply = (250, '?.?.?', expln) ### codes ? headers = [{'label': 'Authent:', \ 'text': '%s NO-ID block=%s ratings=%s' % (IP, ID, ratings)}] return (action, SMTP_reply, headers) methlist = get_methods(parsedrec) if methlist == []: ### Accepting these for now. expln = "No supported methods in Registry for '%s'" % ID SMTP_reply = (250, '', expln + "\nDelivery uncertain.") headers = [{'label': 'Authent:', \ 'text': '%s %s UNVERIFIED (%s)' % (IP, ID, expln)}] return ('CONTINUE', SMTP_reply, headers) ## Sort the method list into the preferred execution order. # R_order - specified by receiver in METHOD_LIST # S_order - specified by sender in Registry record mlist = []; S_order = 0 for meth in methlist: methname = meth[0] R_order = METHOD_LIST[methname][0] mlist.append((R_order, S_order, meth)) # decorate S_order += 1 mlist.sort() # sort methlist = [ml[2] for ml in mlist] # undecorate ## Run the methods in sequence, breaking after the first one with a # final action (anything but CONTINUE). for meth in methlist: methname = meth[0] maxq = meth[1] if parsedrec.has_key(methname): params = parsedrec[methname] else: params = meth[2] action, SMTP_reply, headers = run_method( methname, maxq, params, ID, IP, H, S, R ) if action != 'CONTINUE': headers[0]['text'] += ' ratings=%s' % ratings break else: expln = "No final result from any authentication method." SMTP_reply = (250, '', expln) ### Accept for now headers = ['Authent: %s %s UNVERIFIED' % (IP, ID)] headers = [{'label': 'Authent:', \ 'text': '%s %s UNVERIFIED' % (IP, ID)}] return ('CONTINUE', SMTP_reply, headers) ## Final disposition by this MTA: if action == 'ACCEPT' and RATING == 'LOW': action = 'FILTER' expln = ( "Message will be sent to spam filter - don't depend on delivery.\n" + "Your domain '%s' has ratings %s.\n" % (ID, ratings) + "See open-mail.org/rejects for help." ) SMTP_reply = (250, '?.?.?', expln) ### codes ? return (action, SMTP_reply, headers) ## === Support Functions === def local_test(IP, ID): if VERBOSE > 0: print >> Dfile, ("+++>> Additional parameters used in this " + "test include HELO string = '%s'" % H ) headers = [ {'label': 'Authent:', 'text': '192.0.2.1 example.com SPF1 PASS ratings=S1:A,M2:A,H1:B'} ] if ID == 'example.com': return ('ACCEPT', ( 250, '', 'Sender OK'), headers) else: return ('REJECT', (550, '5.7.1', "Sending MTA '%s' not authorized by '%s'" % (IP, ID)), []) def validate_FQDN( name ): '''Check for a valid fully-qualified domain name. Look for common problems with forged names - easy rejects. ''' try: splits = name.split('.') assert len(splits) > 1 ### more tests here - RFC conformity, etc. except: raise BadFQDN, "'%s' is not a valid domain name." % name def get_ratings( svclist ): ratings = ''; maxs = 0; mins = 9 # Initial values total_s = 0.0; total_w = 0.0; # for weighted average for item in svclist: svc_code = item[0]; rating = item[2] if svc_code not in TRUSTED_SERVICES: continue # ignore unknown services ratings += svc_code + ':' + rating + ',' score = TRUSTED_SERVICES[svc_code][rating] weight = TRUSTED_SERVICES[svc_code]['Wt'] total_s += score * weight total_w += weight maxs = max(maxs, score); mins = min(mins, score) if total_w > 14.0: break # enough to make a decision if total_w == 0.0: raise NoRatings ratings = ratings.rstrip(',') score = total_s / total_w print >> Dfile, ratings, score, mins, maxs return (ratings, score, mins, maxs) def reverse_IP(IP): ''' >>> reverse_IP('1.2.3.4') '4.3.2.1' ''' labels = IP.split('.') labels.reverse() return '.'.join(labels) def ALERT(IP, ID, expln): ## send_alert_to(ADMIN) ## if Logfile: ## print >> Logfile, timestamp + IP + ID + expln pass def get_methods(parsedrec): methlist = [] if parsedrec.has_key('meth'): methods = parseRegRec.parse2(parsedrec['meth']) # [['CSV', 2, ''], ['SPF', 10, 'mx ip4:24.30.18.0/24 ~all']] for m in methods: methname = m[0] if methname not in METHOD_LIST: continue # skip unsupported methods queries = m[1] maxq = METHOD_LIST[methname][1] if queries > maxq: continue # skip methods needing too many queries ## # Alternative to use during a DoS attack. ## expln = "Method %s, needing %s queries, too much \ ## for now. Try again later." % (methname, queries) ## return ('TEMPFAIL', (450, '?.?.?', expln), []) methlist.append(m) elif parsedrec.has_key('ip4'): # simple check against ip4 addresses methlist.append(['QR1', 0, 'ip4:' + parsedrec['ip4']]) elif parsedrec.has_key('ip6'): # simple check against ip6 addresses methlist.append(['QR1', 0, 'ip6:' + parsedrec['ip6']]) return methlist def checkSBL(IP): '''Check the Spamhaus Blacklist for currently spamming IPs. ''' # stub for local testing if IP == '192.0.2.99': return True IPR = reverse_IP(IP) name = IPR + '.sbl-xbl.spamhaus.org' reqobj = DNS.Request(name, qtype='A') resp = reqobj.req() if resp.answers == []: return False else: return True def cidr_parse(ip4): '''Parse a string in CIDR notation. >>> cidr_parse('192.168.255.255/22') ('192.168.255.255', 22) ''' sp = ip4.split('/') if len(sp) == 2: a = sp[0]; m = int(sp[1]) elif len(sp) == 1: a = sp[0]; m = 32 else: raise SyntaxError, ip4 return a, m def cidr_base(a, m): '''Return an integer, the lowest address in the CIDR block with length m that contains address a. >>> hex( cidr_base('192.168.255.255', 22) ) '0xC0A8FC00L' ''' inta = 0 for p in a.split('.'): intp = int(p) if not 0 <= intp < 256: raise SyntaxError, "Number out of range '%s'" % a inta = inta*256 + intp s = 32 - m base = (inta >> s) << s # Mask out the low bits. return base def cidr_match(a, ip4): '''Test if an address 'a' falls withing a CIDR block 'ip4' >>> cidr_match('192.168.255.59', '192.168.255.255/22') True ''' b, m = cidr_parse(ip4) base1 = cidr_base(a, m) base2 = cidr_base(b, m) if base1 == base2: return True else: return False ## === Method Wrappers === def run_method(methname, maxq, params, ID, IP, H, S, R ): '''Call a wrapper function for each method, and translate its return values into the (action, SMTP_reply, headers) format needed by checkID(). ''' if methname == 'SPF': return _spf(maxq, params, ID, IP, H, S ) if methname == 'SID': return _sid(maxq, params, ID, IP, H, S ) if methname == 'CSV': return _csv(maxq, params, ID, IP, H) if methname == 'QR1': return _qr1(params, ID, IP) raise ValueError, "Method '%s' not supported." % methname def _spf(maxq, params, ID, IP, H, S ): '''Runs tests from the spf.py module, using an SPF record in the string params, or getting the SPF record using DNS queries as specifed in the SPF method. >>> _spf(50, '', 'mx1.wayforward.net', '69.55.226.139', \ 'mx1.wayforward.net','tway@optsw.com' ) ('ACCEPT', (250, '', 'sender SPF verified'), \ [{'text': '69.55.226.139 mx1.wayforward.net SPF1 pass', 'label': 'Authent:'}, \ {'text': 'pass (mxR.receiver.com: domain of optsw.com designates 69.55.226.139 \ as permitted sender) client-ip=69.55.226.139; envelope-from=tway@optsw.com; \ helo=mx1.wayforward.net;', 'label': 'Received-SPF:'}]) >>> _spf(50, 'v=spf1 +mx +ip4:10.0.0.1 -all', 'mx1.wayforward.net', \ '10.0.0.1', 'mx1.wayforward.net', 'tway@optsw.com') ('ACCEPT', (250, '', 'sender SPF verified'), \ [{'text': '10.0.0.1 mx1.wayforward.net SPF1 pass', 'label': 'Authent:'}, \ {'text': 'pass (mxR.receiver.com: domain of optsw.com designates 10.0.0.1 \ as permitted sender) client-ip=10.0.0.1; envelope-from=tway@optsw.com; \ helo=mx1.wayforward.net;', 'label': 'Received-SPF:'}]) ''' ## if H == 'mx1.example.com': # stub for local testing ## SMTP_reply = (250, '', 'sender SPF verified') ## header0 = {'label': 'Authent:', \ ## 'text': '%s %s SPF1 PASS' % (IP, ID) } ## header1 = {'label': 'Received-SPF:', \ ## 'text': ''} ## return ('ACCEPT', SMTP_reply, [header0, header1] ) ## print params, ID, IP, H, S ### q_obj = spf.query(IP, S, H ) # an SPF query object if params == '': # run the standard SPF test on S and H result, SMTP_code, expln = spf.check(IP, S, H) else: # use the SPF record in params to run the test result, SMTP_code, expln = q_obj.check(params) SMTP_reply = (SMTP_code, '', expln) header0 = {'label': 'Authent:', 'text': '%s %s SPF1 %s' % (IP, ID, result) } header1 = {'label': 'Received-SPF:', \ 'text': q_obj.get_header(result, RCVR)} if result == 'pass': return ('ACCEPT', SMTP_reply, [header0, header1]) elif result in ('neutral', 'none', 'softfail'): return ('FILTER', SMTP_reply, [header0, header1]) elif result in ('fail', 'unknown'): return ('REJECT', SMTP_reply, [header0, header1]) elif result == 'error': return ('TEMPFAIL', SMTP_reply, [header0, header1]) else: raise ValueError, 'Unrecognized result from SPF check' def _qr1(params, ID, IP): '''Check an IP against an IP block contained in the string 'params'. >>> _qr1('ip4:192.168.128.0/22', 'example.com', '192.168.128.77') ('ACCEPT', (250, '', 'Sender OK'), \ [{'text': '192.168.128.77 example.com QR1 PASS', 'label': 'Authent:'}]) >>> _qr1('ip4:192.0.0.0/22', 'example.com', '192.168.128.77') ('REJECT', (550, '5.7.1', "Sending MTA '192.168.128.77' \ not authorized by 'example.com'"), \ [{'text': '192.168.128.77 example.com QR1 FAIL', 'label': 'Authent:'}]) ''' ip4 = params.split(':')[1] if cidr_match(IP, ip4): result = 'PASS' action = 'ACCEPT' SMTP_reply = (250, '', 'Sender OK') else: result = 'FAIL' action = 'REJECT' expln = "Sending MTA '%s' not authorized by '%s'" % (IP, ID) SMTP_reply = (550, '5.7.1', expln) header0 = {'label': 'Authent:', 'text': '%s %s QR1 %s' % (IP, ID, result) } return (action, SMTP_reply, [header0]) if __name__ == '__main__': def unit_tests(): print ' === UNIT TESTS ===' IP = '192.0.2.1' print '==>', "checkID( IP, 'example.com')\n", checkID( IP, 'example.com') print '==>', "checkID( IP, 'example2.com')\n", checkID( IP, 'example2.com') __test__ = {} # extra doctests __test__['unit_tests'] = ''' >>> unit_tests() === UNIT TESTS === ==> checkID( IP, 'example.com') ('ACCEPT', (250, '', 'Sender OK'), \ [{'text': '192.0.2.1 example.com SPF1 PASS ratings=S1:A,M2:A,H1:B', \ 'label': 'Authent:'}]) ==> checkID( IP, 'example2.com') ('REJECT', (550, '5.7.1', "Sending MTA '192.0.2.1' not authorized by \ 'example2.com'"), []) ''' __test__['Usage_string'] = USAGE import sys, doctest doctest.testmod(sys.modules['__main__'], verbose=True) unit_tests() IP = '192.168.0.1' ID = 'example.com' H = 'mx1.example.com' params = 'v=spf1 +mx +ip4:10.0.0.1 -all' S = 'joe@example.com' R = None ratings = 'S1:B,H1+:D' methname = 'SPF'