@@ -162,13 +162,16 @@ class DMARCTagMap(TypedDict):
162162 adkim : DMARCTagMapItemWithDefault
163163 aspf : DMARCTagMapItemWithDefault
164164 fo : DMARCTagMapItemWithDefaultAndValues
165+ np : DMARCTagMapItemWithValues
165166 p : DMARCTagMapItemWithValues
166167 pct : DMARCTagMapItemWithDefault
168+ psd : DMARCTagMapItemWithDefaultAndValues
167169 rf : DMARCTagMapItemWithDefaultAndValues
168170 ri : DMARCTagMapItemWithDefault
169171 rua : DMARCTagMapItem
170172 ruf : DMARCTagMapItem
171173 sp : DMARCTagMapItemWithValues
174+ t : DMARCTagMapItemWithDefaultAndValues
172175 v : DMARCTagMapItem
173176
174177
@@ -384,9 +387,54 @@ class DMARCErrorData(TypedDict, total=False):
384387 ),
385388 },
386389 },
390+ "np" : {
391+ "name" : "Non-existent Subdomain Policy" ,
392+ "required" : False ,
393+ "description" : (
394+ "Indicates the policy to "
395+ "be enacted by the "
396+ "Receiver at the request "
397+ "of the Domain Owner for "
398+ "non-existent subdomains "
399+ "of the domain queried. "
400+ "Its syntax is identical "
401+ 'to that of the "p" tag. '
402+ "If absent, the policy "
403+ 'specified by the "sp" tag '
404+ "(if present) or the "
405+ '"p" tag MUST be applied '
406+ "for non-existent subdomains."
407+ ),
408+ "values" : {
409+ "none" : (
410+ "The Domain Owner requests "
411+ "no specific action be "
412+ "taken regarding delivery "
413+ "of messages."
414+ ),
415+ "quarantine" : (
416+ "The Domain Owner "
417+ "wishes to have "
418+ "email that fails "
419+ "the DMARC mechanism "
420+ "check be treated by "
421+ "Mail Receivers as "
422+ "suspicious."
423+ ),
424+ "reject" : (
425+ "The Domain Owner wishes "
426+ "for Mail Receivers to "
427+ "reject email that fails "
428+ "the DMARC mechanism check. "
429+ "Rejection SHOULD occur "
430+ "during the SMTP "
431+ "transaction."
432+ ),
433+ },
434+ },
387435 "p" : {
388436 "name" : "Requested Mail Receiver Policy" ,
389- "required" : True ,
437+ "required" : False ,
390438 "description" : (
391439 "Specifies the policy to "
392440 "be enacted by the "
@@ -455,8 +503,46 @@ class DMARCErrorData(TypedDict, total=False):
455503 "Domain Owners to enact "
456504 "a slow rollout of "
457505 "enforcement of the "
458- "DMARC mechanism."
506+ "DMARC mechanism. "
507+ "Removed in DMARCbis."
508+ ),
509+ },
510+ "psd" : {
511+ "name" : "PSD Flag" ,
512+ "required" : False ,
513+ "default" : "u" ,
514+ "description" : (
515+ "A flag indicating "
516+ "whether the domain is a "
517+ "Public Suffix Domain (PSD)."
459518 ),
519+ "values" : {
520+ "y" : (
521+ "The domain is a PSD. "
522+ "This tag is included "
523+ "by PSOs to indicate "
524+ "that the domain is a "
525+ "Public Suffix Domain."
526+ ),
527+ "n" : (
528+ "The DMARC Policy Record "
529+ "is published for a domain "
530+ "that is not a PSD, but it "
531+ "is the Organizational "
532+ "Domain for itself and "
533+ "its subdomains."
534+ ),
535+ "u" : (
536+ "The default indicates "
537+ "that the DMARC Policy "
538+ "Record is published for "
539+ "a domain that is not a "
540+ "PSD, and may or may not "
541+ "be an Organizational "
542+ "Domain for itself and "
543+ "its subdomains."
544+ ),
545+ },
460546 },
461547 "rf" : {
462548 "name" : "Report Format" ,
@@ -476,7 +562,8 @@ class DMARCErrorData(TypedDict, total=False):
476562 "(the auth-failure report "
477563 "type) is currently "
478564 "supported in the "
479- "DMARC standard."
565+ "DMARC standard. "
566+ "Removed in DMARCbis."
480567 ),
481568 "values" : {
482569 "afrf" : (
@@ -509,7 +596,8 @@ class DMARCErrorData(TypedDict, total=False):
509596 "than a daily report is "
510597 "understood to be "
511598 "accommodated on a "
512- "best-effort basis."
599+ "best-effort basis. "
600+ "Removed in DMARCbis."
513601 ),
514602 },
515603 "rua" : {
@@ -585,6 +673,46 @@ class DMARCErrorData(TypedDict, total=False):
585673 ),
586674 },
587675 },
676+ "t" : {
677+ "name" : "DMARC Policy Test Mode" ,
678+ "required" : False ,
679+ "default" : "n" ,
680+ "description" : (
681+ "Signals whether or not "
682+ "the Domain Owner wishes "
683+ "the Domain Owner Assessment "
684+ "Policy declared in the "
685+ '"p", "sp", and/or "np" tags '
686+ "to actually be applied. "
687+ "This tag does not affect "
688+ "the generation of DMARC "
689+ "reports, and it has no "
690+ "effect on any policy that "
691+ 'is "none".'
692+ ),
693+ "values" : {
694+ "y" : (
695+ "A request that the actor "
696+ "performing the DMARC "
697+ "validation check not "
698+ "apply the policy, but "
699+ "instead apply any special "
700+ "handling rules it might "
701+ "have in place. The Domain "
702+ "Owner is currently testing "
703+ "its specified DMARC "
704+ "assessment policy."
705+ ),
706+ "n" : (
707+ "The default is a request "
708+ "to apply the Domain Owner "
709+ "Assessment Policy as "
710+ "specified to any message "
711+ "that produces a DMARC "
712+ '"fail" result.'
713+ ),
714+ },
715+ },
588716 "v" : {
589717 "name" : "Version" ,
590718 "required" : True ,
@@ -692,7 +820,7 @@ def _query_dmarc_record(
692820 except dns .resolver .NoAnswer :
693821 pass
694822 except dns .resolver .NXDOMAIN :
695- raise DMARCRecordNotFound (f"The domain { 0 } does not exist." . format ( domain ) )
823+ raise DMARCRecordNotFound (f"The domain { domain } does not exist." )
696824 except Exception as error :
697825 raise DMARCRecordNotFound (error )
698826
@@ -744,10 +872,10 @@ def query_dmarc_record(
744872 :exc:`checkdmarc.dmarc.SPFRecordFoundWhereDMARCRecordShouldBe`
745873
746874 """
875+ domain = normalize_domain (domain ).rstrip ("." )
747876 logging .debug (f"Checking for a DMARC record on { domain } " )
748877 warnings = []
749- base_domain = get_base_domain (domain )
750- location = domain .lower ()
878+ location = domain
751879
752880 try :
753881 record = _query_dmarc_record (
@@ -759,7 +887,7 @@ def query_dmarc_record(
759887 ignore_unrelated_records = ignore_unrelated_records ,
760888 )
761889 except DMARCRecordNotFound :
762- # Skip this exception as we want to query the base domain . If we fail
890+ # Skip this exception as we want to perform a tree walk . If we fail
763891 # at that, at the end of this function we will raise another
764892 # DMARCRecordNotFound.
765893 record = None
@@ -781,22 +909,51 @@ def query_dmarc_record(
781909 except dns .exception .DNSException :
782910 pass
783911
784- if record is None and domain != base_domain :
785- record = _query_dmarc_record (
786- base_domain ,
787- nameservers = nameservers ,
788- resolver = resolver ,
789- timeout = timeout ,
790- timeout_retries = timeout_retries ,
791- ignore_unrelated_records = ignore_unrelated_records ,
792- )
793- location = base_domain
912+ # DMARCbis DNS tree walk for DMARC policy discovery
913+ if record is None :
914+ labels = domain .split ("." )
915+ num_labels = len (labels )
916+ if num_labels > 1 :
917+ # Determine starting point for tree walk
918+ if num_labels <= 8 :
919+ # Start from the parent domain (remove leftmost label)
920+ start = 1
921+ else :
922+ # Skip to 7 labels
923+ start = num_labels - 7
924+ # Walk up the tree
925+ for i in range (start , num_labels ):
926+ parent = "." .join (labels [i :])
927+ if "." not in parent :
928+ # Don't query TLDs
929+ break
930+ try :
931+ record = _query_dmarc_record (
932+ parent ,
933+ nameservers = nameservers ,
934+ resolver = resolver ,
935+ timeout = timeout ,
936+ timeout_retries = timeout_retries ,
937+ ignore_unrelated_records = ignore_unrelated_records ,
938+ )
939+ if record is not None :
940+ location = parent
941+ break
942+ except DMARCRecordNotFound :
943+ # No DMARC record at this parent; continue walking up the tree
944+ continue
945+ except DMARCError :
946+ # A DMARC record exists but is invalid or otherwise problematic;
947+ # re-raise so the caller can surface the actual configuration error.
948+ raise
949+
794950 if record is None :
795951 error_str = "A DMARC record does not exist"
796- if domain == base_domain :
952+ labels = domain .split ("." )
953+ if len (labels ) <= 2 :
797954 error_str += "."
798955 else :
799- error_str += " for this subdomain or its base domain ."
956+ error_str += " for this domain or its parent domains ."
800957 raise DMARCRecordNotFound (error_str )
801958
802959 return {"record" : record , "location" : location , "warnings" : warnings }
@@ -825,6 +982,8 @@ def get_dmarc_tag_description(
825982 allowed_values = {}
826983 if "default" in dmarc_tags [tag ]:
827984 default = dmarc_tags [tag ]["default" ]
985+ if "values" in dmarc_tags [tag ]:
986+ allowed_values = dmarc_tags [tag ]["values" ]
828987 if type (value ) is str and value in allowed_values :
829988 description = allowed_values [value ]
830989 elif type (value ) is list and len (allowed_values ):
@@ -1185,15 +1344,36 @@ def parse_dmarc_record(
11851344 if tag not in tags and "default" in dmarc_tags [tag ]:
11861345 tags [tag ] = {"value" : dmarc_tags [tag ]["default" ], "explicit" : False }
11871346 if "p" not in tags :
1188- raise DMARCSyntaxError ('The record is missing the required policy ("p") tag.' )
1347+ tags ["p" ] = {"value" : "none" , "explicit" : False }
1348+ warnings .append (
1349+ "The p tag is optional in DMARCbis, but is required "
1350+ "in older versions of DMARC."
1351+ )
11891352 tags ["p" ]["value" ] = tags ["p" ]["value" ].lower ()
11901353 if "sp" not in tags :
11911354 tags ["sp" ] = {"value" : tags ["p" ]["value" ], "explicit" : False }
11921355 # Normalize sp value for validation consistency (mirrors p behavior)
11931356 tags ["sp" ]["value" ] = tags ["sp" ]["value" ].lower ()
1194- if list (tags .keys ())[1 ] != "p" :
1195- raise DMARCSyntaxError ("the p tag must immediately follow the v tag." )
1357+ if "np" not in tags :
1358+ if tags ["sp" ]["explicit" ]:
1359+ tags ["np" ] = {"value" : tags ["sp" ]["value" ], "explicit" : False }
1360+ else :
1361+ tags ["np" ] = {"value" : tags ["p" ]["value" ], "explicit" : False }
1362+ tags ["np" ]["value" ] = tags ["np" ]["value" ].lower ()
1363+ # The p tag must immediately follow v if it is explicit
1364+ if tags ["p" ]["explicit" ]:
1365+ if list (tags .keys ())[1 ] != "p" :
1366+ raise DMARCSyntaxError ("the p tag must immediately follow the v tag." )
11961367 tags ["v" ]["value" ] = tags ["v" ]["value" ].upper ()
1368+
1369+ # Warn about tags removed in DMARCbis
1370+ removed_tags = ("pct" , "rf" , "ri" )
1371+ for tag in removed_tags :
1372+ if tag in tags and tags [tag ]["explicit" ]:
1373+ warnings .append (
1374+ f"Support for the { tag } tag was removed in DMARCbis."
1375+ )
1376+
11971377 # Validate tag values
11981378 for tag in tags :
11991379 tag_value = tags [tag ]["value" ]
0 commit comments