dmarc: implement DMARCbis DNS Tree Walk and new tag support
Replace RFC 7489 PSL-based org-domain lookup and RFC 9091 PSD DMARC fallback with the DMARCbis DNS Tree Walk algorithm (max 8 queries, 8-label shortcut, TLD records require psd=y). Add parsing for the new t= (test mode), psd= (y/n/u), and deprecated tag detection (pct, rf, ri). Update validateDMARC to accept p=-absent records with rua= per DMARCbis §4.7. Score t=y by downgrading effective policy one level. Surface user-facing advisories in DmarcRecordDisplay: deprecation warnings for pct=/rf=/ri=, test mode explanation with per-policy impact, and PSD/org-domain boundary notices.
This commit is contained in:
parent
1b8627ef86
commit
809bca02e4
4 changed files with 482 additions and 159 deletions
|
|
@ -926,14 +926,35 @@ components:
|
||||||
nonexistent_subdomain_policy:
|
nonexistent_subdomain_policy:
|
||||||
type: string
|
type: string
|
||||||
enum: [none, quarantine, reject, unknown]
|
enum: [none, quarantine, reject, unknown]
|
||||||
description: DMARC non-existent subdomain policy (np tag) - policy for non-existent subdomains (NXDOMAIN); defaults to sp= or p= if absent
|
description: DMARC non-existent subdomain policy (np tag) - policy for non-existent subdomains (NXDOMAIN); defaults to sp= or p= if absent (DMARCbis)
|
||||||
example: "reject"
|
example: "reject"
|
||||||
percentage:
|
percentage:
|
||||||
type: integer
|
type: integer
|
||||||
minimum: 0
|
minimum: 0
|
||||||
maximum: 100
|
maximum: 100
|
||||||
description: Percentage of messages subjected to filtering (pct tag, default 100)
|
description: "Percentage of messages subjected to filtering (pct tag, default 100). DEPRECATED in DMARCbis: use test_mode (t=y) instead."
|
||||||
example: 100
|
example: 100
|
||||||
|
test_mode:
|
||||||
|
type: boolean
|
||||||
|
description: "DMARCbis t= tag: when true (t=y), receivers downgrade effective policy one level (reject→quarantine, quarantine→none). Replaces the deprecated pct= tag for testing."
|
||||||
|
example: false
|
||||||
|
psd:
|
||||||
|
type: string
|
||||||
|
enum: [y, n, u]
|
||||||
|
description: "DMARCbis psd= tag: y=this is a Public Suffix Domain, n=this is an Organizational Domain boundary, u=unknown (default, use DNS Tree Walk to determine)"
|
||||||
|
example: "u"
|
||||||
|
deprecated_pct:
|
||||||
|
type: boolean
|
||||||
|
description: "Whether the deprecated pct= tag was found in the record (pct is removed in DMARCbis; migrate to t=y for testing mode)"
|
||||||
|
example: false
|
||||||
|
deprecated_rf:
|
||||||
|
type: boolean
|
||||||
|
description: "Whether the deprecated rf= tag was found in the record (rf is removed in DMARCbis; failure report formats are now defined separately)"
|
||||||
|
example: false
|
||||||
|
deprecated_ri:
|
||||||
|
type: boolean
|
||||||
|
description: "Whether the deprecated ri= tag was found in the record (ri is removed in DMARCbis; aggregate reporting interval is now fixed at ≥24 hours)"
|
||||||
|
example: false
|
||||||
spf_alignment:
|
spf_alignment:
|
||||||
type: string
|
type: string
|
||||||
enum: [relaxed, strict]
|
enum: [relaxed, strict]
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,6 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/net/publicsuffix"
|
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
@ -62,85 +60,103 @@ func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMA
|
||||||
subdomainPolicy := d.extractDMARCSubdomainPolicy(rawRecord)
|
subdomainPolicy := d.extractDMARCSubdomainPolicy(rawRecord)
|
||||||
nonexistentSubdomainPolicy := d.extractDMARCNonexistentSubdomainPolicy(rawRecord)
|
nonexistentSubdomainPolicy := d.extractDMARCNonexistentSubdomainPolicy(rawRecord)
|
||||||
percentage := d.extractDMARCPercentage(rawRecord)
|
percentage := d.extractDMARCPercentage(rawRecord)
|
||||||
|
testMode := d.extractDMARCTestMode(rawRecord)
|
||||||
|
psd := d.extractDMARCPSD(rawRecord)
|
||||||
spfAlignment := d.extractDMARCSPFAlignment(rawRecord)
|
spfAlignment := d.extractDMARCSPFAlignment(rawRecord)
|
||||||
dkimAlignment := d.extractDMARCDKIMAlignment(rawRecord)
|
dkimAlignment := d.extractDMARCDKIMAlignment(rawRecord)
|
||||||
|
deprecatedPct := percentage != nil
|
||||||
|
deprecatedRf := d.hasDMARCTag(rawRecord, "rf")
|
||||||
|
deprecatedRi := d.hasDMARCTag(rawRecord, "ri")
|
||||||
|
|
||||||
|
rec := &model.DMARCRecord{
|
||||||
|
Domain: &foundDomain,
|
||||||
|
Record: &rawRecord,
|
||||||
|
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
|
||||||
|
SubdomainPolicy: subdomainPolicy,
|
||||||
|
NonexistentSubdomainPolicy: nonexistentSubdomainPolicy,
|
||||||
|
Percentage: percentage,
|
||||||
|
TestMode: testMode,
|
||||||
|
Psd: psd,
|
||||||
|
SpfAlignment: spfAlignment,
|
||||||
|
DkimAlignment: dkimAlignment,
|
||||||
|
}
|
||||||
|
if deprecatedPct {
|
||||||
|
rec.DeprecatedPct = utils.PtrTo(true)
|
||||||
|
}
|
||||||
|
if deprecatedRf {
|
||||||
|
rec.DeprecatedRf = utils.PtrTo(true)
|
||||||
|
}
|
||||||
|
if deprecatedRi {
|
||||||
|
rec.DeprecatedRi = utils.PtrTo(true)
|
||||||
|
}
|
||||||
|
|
||||||
if !d.validateDMARC(rawRecord) {
|
if !d.validateDMARC(rawRecord) {
|
||||||
return &model.DMARCRecord{
|
rec.Valid = false
|
||||||
Domain: &foundDomain,
|
rec.Error = utils.PtrTo("DMARC record appears malformed")
|
||||||
Record: &rawRecord,
|
return rec
|
||||||
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
|
}
|
||||||
SubdomainPolicy: subdomainPolicy,
|
|
||||||
NonexistentSubdomainPolicy: nonexistentSubdomainPolicy,
|
rec.Valid = true
|
||||||
Percentage: percentage,
|
return rec
|
||||||
SpfAlignment: spfAlignment,
|
}
|
||||||
DkimAlignment: dkimAlignment,
|
|
||||||
Valid: false,
|
// walkDNSForDMARC implements the DMARCbis DNS Tree Walk algorithm (Section 4.10).
|
||||||
Error: utils.PtrTo("DMARC record appears malformed"),
|
// It queries _dmarc.<domain> and walks up the label hierarchy until a valid DMARC
|
||||||
|
// record is found or all labels are exhausted. Maximum 8 DNS queries per message.
|
||||||
|
// For domains with ≥8 labels, after the initial miss the walk jumps to the 7-label
|
||||||
|
// suffix before resuming normally (to stay within the 8-query budget).
|
||||||
|
// Single-label (TLD) records are only accepted when they carry psd=y.
|
||||||
|
func (d *DNSAnalyzer) walkDNSForDMARC(domain string) (record, foundDomain string, err error) {
|
||||||
|
labels := strings.Split(strings.ToLower(strings.TrimSuffix(domain, ".")), ".")
|
||||||
|
n := len(labels)
|
||||||
|
|
||||||
|
for i, queries := 0, 0; i < n && queries < 8; i, queries = i+1, queries+1 {
|
||||||
|
current := strings.Join(labels[i:], ".")
|
||||||
|
|
||||||
|
raw, notFound, lookupErr := d.lookupDMARCAt(current)
|
||||||
|
if lookupErr != nil {
|
||||||
|
return "", "", lookupErr
|
||||||
|
}
|
||||||
|
if !notFound {
|
||||||
|
// Single-label (TLD) records are only used when the record explicitly opts in.
|
||||||
|
if !strings.Contains(current, ".") {
|
||||||
|
if d.extractDMARCPSDValue(raw) != "y" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw, current, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMARCbis §4.10: after missing on a ≥8-label domain, shortcut to the
|
||||||
|
// 7-label suffix for the next query rather than stepping one label at a time.
|
||||||
|
if i == 0 && n >= 8 {
|
||||||
|
i = n - 8 // the outer i++ will land at n-7 (7 labels from the right)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &model.DMARCRecord{
|
return "", "", nil
|
||||||
Domain: &foundDomain,
|
|
||||||
Record: &rawRecord,
|
|
||||||
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
|
|
||||||
SubdomainPolicy: subdomainPolicy,
|
|
||||||
NonexistentSubdomainPolicy: nonexistentSubdomainPolicy,
|
|
||||||
Percentage: percentage,
|
|
||||||
SpfAlignment: spfAlignment,
|
|
||||||
DkimAlignment: dkimAlignment,
|
|
||||||
Valid: true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkDMARCRecord looks up and validates the DMARC record for a domain.
|
// checkDMARCRecord looks up and validates the DMARC record for a domain using
|
||||||
// It follows RFC 7489 §6.6.3 fallback to the Organizational Domain and
|
// the DMARCbis DNS Tree Walk algorithm (Section 4.10), which supersedes the
|
||||||
// RFC 9091 optional fallback to the Public Suffix Domain (only when psd=y).
|
// RFC 7489 PSL-based organizational domain lookup and the RFC 9091 PSD DMARC
|
||||||
|
// experimental fallback.
|
||||||
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
|
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
|
||||||
// Step 1: try exact domain (_dmarc.<domain>)
|
raw, foundDomain, err := d.walkDNSForDMARC(domain)
|
||||||
raw, notFound, err := d.lookupDMARCAt(domain)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &model.DMARCRecord{
|
return &model.DMARCRecord{
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !notFound {
|
if foundDomain == "" {
|
||||||
return d.parseDMARCRecord(domain, raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: RFC 7489 — fall back to Organizational Domain (eTLD+1)
|
|
||||||
orgDomain := getOrganizationalDomain(domain)
|
|
||||||
if orgDomain != domain {
|
|
||||||
raw, notFound, err = d.lookupDMARCAt(orgDomain)
|
|
||||||
if err != nil {
|
|
||||||
return &model.DMARCRecord{
|
|
||||||
Valid: false,
|
|
||||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !notFound {
|
|
||||||
return d.parseDMARCRecord(orgDomain, raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: RFC 9091 — fall back to Public Suffix Domain when psd=y
|
|
||||||
psd, _ := publicsuffix.PublicSuffix(domain)
|
|
||||||
if psd != "" && psd != orgDomain {
|
|
||||||
raw, notFound, err = d.lookupDMARCAt(psd)
|
|
||||||
if err == nil && !notFound {
|
|
||||||
// Only apply PSD DMARC when the record explicitly opts in with psd=y
|
|
||||||
if strings.Contains(raw, "psd=y") {
|
|
||||||
return d.parseDMARCRecord(psd, raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.DMARCRecord{
|
return &model.DMARCRecord{
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo("No DMARC record found"),
|
Error: utils.PtrTo("No DMARC record found"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return d.parseDMARCRecord(foundDomain, raw)
|
||||||
|
}
|
||||||
|
|
||||||
// extractDMARCPolicy extracts the policy from a DMARC record
|
// extractDMARCPolicy extracts the policy from a DMARC record
|
||||||
func (d *DNSAnalyzer) extractDMARCPolicy(record string) string {
|
func (d *DNSAnalyzer) extractDMARCPolicy(record string) string {
|
||||||
|
|
@ -211,115 +227,157 @@ func (d *DNSAnalyzer) extractDMARCNonexistentSubdomainPolicy(record string) *mod
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractDMARCPercentage extracts the percentage from a DMARC record
|
// extractDMARCPercentage extracts the percentage from a DMARC record.
|
||||||
// Returns the pct tag value or nil if not specified (defaults to 100)
|
// Returns the pct tag value or nil if not specified (defaults to 100).
|
||||||
|
// Note: pct= is deprecated in DMARCbis; use t= (test_mode) instead.
|
||||||
func (d *DNSAnalyzer) extractDMARCPercentage(record string) *int {
|
func (d *DNSAnalyzer) extractDMARCPercentage(record string) *int {
|
||||||
// Look for pct=<number>
|
|
||||||
re := regexp.MustCompile(`pct=(\d+)`)
|
re := regexp.MustCompile(`pct=(\d+)`)
|
||||||
matches := re.FindStringSubmatch(record)
|
matches := re.FindStringSubmatch(record)
|
||||||
if len(matches) > 1 {
|
if len(matches) > 1 {
|
||||||
// Convert string to int
|
|
||||||
var pct int
|
var pct int
|
||||||
fmt.Sscanf(matches[1], "%d", &pct)
|
fmt.Sscanf(matches[1], "%d", &pct)
|
||||||
// Validate range (0-100)
|
|
||||||
if pct >= 0 && pct <= 100 {
|
if pct >= 0 && pct <= 100 {
|
||||||
return &pct
|
return &pct
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Default is 100 if not specified
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateDMARC performs basic DMARC record validation
|
// extractDMARCTestMode extracts the DMARCbis t= tag (test mode).
|
||||||
|
// Returns true for t=y, false for t=n, nil if absent (defaults to false / full enforcement).
|
||||||
|
func (d *DNSAnalyzer) extractDMARCTestMode(record string) *bool {
|
||||||
|
re := regexp.MustCompile(`(?:^|;)\s*t=(y|n)(?:;|$|\s)`)
|
||||||
|
matches := re.FindStringSubmatch(record)
|
||||||
|
if len(matches) > 1 {
|
||||||
|
v := matches[1] == "y"
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDMARCPSD extracts the DMARCbis psd= tag value as a typed enum.
|
||||||
|
// Returns nil if the tag is absent (defaults to "u" / unknown).
|
||||||
|
func (d *DNSAnalyzer) extractDMARCPSD(record string) *model.DMARCRecordPsd {
|
||||||
|
v := d.extractDMARCPSDValue(record)
|
||||||
|
if v == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return utils.PtrTo(model.DMARCRecordPsd(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDMARCPSDValue returns the raw string value of psd= ("y", "n", "u") or "".
|
||||||
|
func (d *DNSAnalyzer) extractDMARCPSDValue(record string) string {
|
||||||
|
re := regexp.MustCompile(`(?:^|;)\s*psd=(y|n|u)(?:;|$|\s)`)
|
||||||
|
matches := re.FindStringSubmatch(record)
|
||||||
|
if len(matches) > 1 {
|
||||||
|
return matches[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasDMARCTag reports whether the given tag name appears in the record.
|
||||||
|
func (d *DNSAnalyzer) hasDMARCTag(record, tag string) bool {
|
||||||
|
re := regexp.MustCompile(`(?:^|;)\s*` + regexp.QuoteMeta(tag) + `=`)
|
||||||
|
return re.MatchString(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateDMARC performs basic DMARC record validation.
|
||||||
|
// Per DMARCbis, p= is now RECOMMENDED (not required): a record with a valid
|
||||||
|
// rua= but no p= is treated as p=none and considered valid.
|
||||||
func (d *DNSAnalyzer) validateDMARC(record string) bool {
|
func (d *DNSAnalyzer) validateDMARC(record string) bool {
|
||||||
// Must start with v=DMARC1
|
|
||||||
if !strings.HasPrefix(record, "v=DMARC1") {
|
if !strings.HasPrefix(record, "v=DMARC1") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must have a policy tag
|
// p= absent is allowed in DMARCbis when rua= is present (treated as p=none).
|
||||||
if !strings.Contains(record, "p=") {
|
if !strings.Contains(record, "p=") {
|
||||||
return false
|
return strings.Contains(record, "rua=")
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) {
|
func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) {
|
||||||
// DMARC ties SPF and DKIM together and provides policy
|
if results.DmarcRecord == nil {
|
||||||
if results.DmarcRecord != nil {
|
return
|
||||||
if results.DmarcRecord.Valid {
|
}
|
||||||
|
|
||||||
|
if !results.DmarcRecord.Valid {
|
||||||
|
if results.DmarcRecord.Record != nil {
|
||||||
|
// Partial credit if a DMARC record exists but has issues
|
||||||
|
score += 20
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
score += 50
|
score += 50
|
||||||
// Bonus points for stricter policies
|
|
||||||
|
// Determine effective policy: DMARCbis t=y downgrades policy one level.
|
||||||
|
effectivePolicy := "none"
|
||||||
if results.DmarcRecord.Policy != nil {
|
if results.DmarcRecord.Policy != nil {
|
||||||
switch *results.DmarcRecord.Policy {
|
effectivePolicy = string(*results.DmarcRecord.Policy)
|
||||||
|
}
|
||||||
|
testMode := results.DmarcRecord.TestMode != nil && *results.DmarcRecord.TestMode
|
||||||
|
if testMode {
|
||||||
|
switch effectivePolicy {
|
||||||
case "reject":
|
case "reject":
|
||||||
// Strictest policy - full points already awarded
|
effectivePolicy = "quarantine"
|
||||||
score += 25
|
|
||||||
case "quarantine":
|
case "quarantine":
|
||||||
// Good policy - no deduction
|
effectivePolicy = "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus/penalty for policy strength
|
||||||
|
switch effectivePolicy {
|
||||||
|
case "reject":
|
||||||
|
score += 25
|
||||||
case "none":
|
case "none":
|
||||||
// Weakest policy - deduct 25 points
|
|
||||||
score -= 25
|
score -= 25
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Bonus points for strict alignment modes (5 points each)
|
// Bonus points for strict alignment modes
|
||||||
if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == model.DMARCRecordSpfAlignmentStrict {
|
if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == model.DMARCRecordSpfAlignmentStrict {
|
||||||
score += 5
|
score += 5
|
||||||
}
|
}
|
||||||
if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == model.DMARCRecordDkimAlignmentStrict {
|
if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == model.DMARCRecordDkimAlignmentStrict {
|
||||||
score += 5
|
score += 5
|
||||||
}
|
}
|
||||||
// Policy strength: none < quarantine < reject
|
|
||||||
policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2}
|
|
||||||
mainPolicy := string(*results.DmarcRecord.Policy)
|
|
||||||
|
|
||||||
// Subdomain policy scoring (sp tag)
|
policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2}
|
||||||
// +15 for stricter or equal subdomain policy, -15 for weaker
|
|
||||||
|
// Subdomain policy scoring (sp tag): +15 for equal-or-stricter, -15 for weaker
|
||||||
if results.DmarcRecord.SubdomainPolicy != nil {
|
if results.DmarcRecord.SubdomainPolicy != nil {
|
||||||
subPolicy := string(*results.DmarcRecord.SubdomainPolicy)
|
subPolicy := string(*results.DmarcRecord.SubdomainPolicy)
|
||||||
mainStrength := policyStrength[mainPolicy]
|
if policyStrength[subPolicy] >= policyStrength[effectivePolicy] {
|
||||||
subStrength := policyStrength[subPolicy]
|
score += 15
|
||||||
|
} else {
|
||||||
|
score -= 15
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
score += 15 // inherits main policy — good default
|
||||||
|
}
|
||||||
|
|
||||||
if subStrength >= mainStrength {
|
|
||||||
// Subdomain policy is equal or stricter
|
|
||||||
score += 15
|
|
||||||
} else {
|
|
||||||
// Subdomain policy is weaker
|
|
||||||
score -= 15
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No sp tag means subdomains inherit main policy (good default)
|
|
||||||
score += 15
|
|
||||||
}
|
|
||||||
// Non-existent subdomain policy scoring (np tag, DMARCbis)
|
// Non-existent subdomain policy scoring (np tag, DMARCbis)
|
||||||
// -15 from base; +15 back if absent (good default) or >= effective sp/p strength
|
|
||||||
score -= 15
|
score -= 15
|
||||||
effectiveSubPolicy := mainPolicy
|
effectiveSubPolicy := effectivePolicy
|
||||||
if results.DmarcRecord.SubdomainPolicy != nil {
|
if results.DmarcRecord.SubdomainPolicy != nil {
|
||||||
effectiveSubPolicy = string(*results.DmarcRecord.SubdomainPolicy)
|
effectiveSubPolicy = string(*results.DmarcRecord.SubdomainPolicy)
|
||||||
}
|
}
|
||||||
if results.DmarcRecord.NonexistentSubdomainPolicy == nil {
|
if results.DmarcRecord.NonexistentSubdomainPolicy == nil {
|
||||||
score += 15
|
score += 15 // inherits subdomain/main policy — good default
|
||||||
} else {
|
} else {
|
||||||
npStrength := policyStrength[string(*results.DmarcRecord.NonexistentSubdomainPolicy)]
|
npStrength := policyStrength[string(*results.DmarcRecord.NonexistentSubdomainPolicy)]
|
||||||
effectiveStrength := policyStrength[effectiveSubPolicy]
|
if npStrength >= policyStrength[effectiveSubPolicy] {
|
||||||
if npStrength >= effectiveStrength {
|
|
||||||
score += 15
|
score += 15
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Percentage scoring (pct tag)
|
|
||||||
// Apply the percentage on the current score
|
// pct= scaling (deprecated in DMARCbis, kept for backward compatibility).
|
||||||
|
// pct=0 is an anti-pattern: score it as zero enforcement.
|
||||||
if results.DmarcRecord.Percentage != nil {
|
if results.DmarcRecord.Percentage != nil {
|
||||||
pct := *results.DmarcRecord.Percentage
|
pct := *results.DmarcRecord.Percentage
|
||||||
|
|
||||||
score = score * pct / 100
|
score = score * pct / 100
|
||||||
}
|
}
|
||||||
} else if results.DmarcRecord.Record != nil {
|
|
||||||
// Partial credit if DMARC record exists but has issues
|
|
||||||
score += 20
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ func TestCheckDMARCRecordFallback(t *testing.T) {
|
||||||
wantDomain: utils.PtrTo("mail.example.com"),
|
wantDomain: utils.PtrTo("mail.example.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "exact domain NXDOMAIN — falls back to org domain",
|
name: "exact domain NXDOMAIN — tree walk reaches org domain",
|
||||||
domain: "mail.example.com",
|
domain: "mail.example.com",
|
||||||
txt: map[string][]string{
|
txt: map[string][]string{
|
||||||
"_dmarc.example.com": {orgRecord},
|
"_dmarc.example.com": {orgRecord},
|
||||||
|
|
@ -99,7 +99,7 @@ func TestCheckDMARCRecordFallback(t *testing.T) {
|
||||||
wantDomain: utils.PtrTo("example.com"),
|
wantDomain: utils.PtrTo("example.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "exact domain has no v=DMARC1 TXT — falls back to org domain",
|
name: "exact domain has no v=DMARC1 TXT — tree walk reaches org domain",
|
||||||
domain: "mail.example.com",
|
domain: "mail.example.com",
|
||||||
txt: map[string][]string{
|
txt: map[string][]string{
|
||||||
"_dmarc.mail.example.com": {"some-other-txt"},
|
"_dmarc.mail.example.com": {"some-other-txt"},
|
||||||
|
|
@ -109,7 +109,7 @@ func TestCheckDMARCRecordFallback(t *testing.T) {
|
||||||
wantDomain: utils.PtrTo("example.com"),
|
wantDomain: utils.PtrTo("example.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "both exact and org NXDOMAIN but PSD has psd=y — RFC 9091 fallback",
|
name: "both exact and org NXDOMAIN but PSD (TLD) has psd=y — DMARCbis Tree Walk",
|
||||||
domain: "mail.example.com",
|
domain: "mail.example.com",
|
||||||
txt: map[string][]string{
|
txt: map[string][]string{
|
||||||
"_dmarc.com": {psdRecord},
|
"_dmarc.com": {psdRecord},
|
||||||
|
|
@ -118,7 +118,7 @@ func TestCheckDMARCRecordFallback(t *testing.T) {
|
||||||
wantDomain: utils.PtrTo("com"),
|
wantDomain: utils.PtrTo("com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "PSD record exists but no psd=y — no record returned",
|
name: "PSD record exists but no psd=y — TLD record ignored by Tree Walk",
|
||||||
domain: "mail.example.com",
|
domain: "mail.example.com",
|
||||||
txt: map[string][]string{
|
txt: map[string][]string{
|
||||||
"_dmarc.com": {"v=DMARC1; p=none"},
|
"_dmarc.com": {"v=DMARC1; p=none"},
|
||||||
|
|
@ -134,7 +134,7 @@ func TestCheckDMARCRecordFallback(t *testing.T) {
|
||||||
wantErrSubst: "No DMARC record found",
|
wantErrSubst: "No DMARC record found",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "DNS error on exact domain — no fallback, error returned",
|
name: "DNS error on exact domain — error returned",
|
||||||
domain: "mail.example.com",
|
domain: "mail.example.com",
|
||||||
errMap: map[string]error{
|
errMap: map[string]error{
|
||||||
"_dmarc.mail.example.com": fmt.Errorf("SERVFAIL"),
|
"_dmarc.mail.example.com": fmt.Errorf("SERVFAIL"),
|
||||||
|
|
@ -143,7 +143,7 @@ func TestCheckDMARCRecordFallback(t *testing.T) {
|
||||||
wantErrSubst: "SERVFAIL",
|
wantErrSubst: "SERVFAIL",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "domain already at org level — no redundant fallback",
|
name: "domain already at org level — found immediately",
|
||||||
domain: "example.com",
|
domain: "example.com",
|
||||||
txt: map[string][]string{
|
txt: map[string][]string{
|
||||||
"_dmarc.example.com": {orgRecord},
|
"_dmarc.example.com": {orgRecord},
|
||||||
|
|
@ -151,6 +151,33 @@ func TestCheckDMARCRecordFallback(t *testing.T) {
|
||||||
wantValid: true,
|
wantValid: true,
|
||||||
wantDomain: utils.PtrTo("example.com"),
|
wantDomain: utils.PtrTo("example.com"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "deep subdomain — tree walk finds record two levels up",
|
||||||
|
domain: "a.b.example.com",
|
||||||
|
txt: map[string][]string{
|
||||||
|
"_dmarc.example.com": {orgRecord},
|
||||||
|
},
|
||||||
|
wantValid: true,
|
||||||
|
wantDomain: utils.PtrTo("example.com"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "8-label domain — shortcut to 7-label suffix on miss",
|
||||||
|
domain: "a.b.c.d.e.f.example.com",
|
||||||
|
txt: map[string][]string{
|
||||||
|
"_dmarc.b.c.d.e.f.example.com": {orgRecord},
|
||||||
|
},
|
||||||
|
wantValid: true,
|
||||||
|
wantDomain: utils.PtrTo("b.c.d.e.f.example.com"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "psd=n record stops tree walk at that level",
|
||||||
|
domain: "mail.sub.example.com",
|
||||||
|
txt: map[string][]string{
|
||||||
|
"_dmarc.sub.example.com": {"v=DMARC1; p=reject; psd=n"},
|
||||||
|
},
|
||||||
|
wantValid: true,
|
||||||
|
wantDomain: utils.PtrTo("sub.example.com"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|
@ -234,6 +261,124 @@ func TestExtractDMARCPolicy(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractDMARCTestMode(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
record string
|
||||||
|
wantMode *bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "t=y sets test mode",
|
||||||
|
record: "v=DMARC1; p=reject; t=y",
|
||||||
|
wantMode: utils.PtrTo(true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "t=n explicitly disables test mode",
|
||||||
|
record: "v=DMARC1; p=reject; t=n",
|
||||||
|
wantMode: utils.PtrTo(false),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "absent t tag returns nil",
|
||||||
|
record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
|
||||||
|
wantMode: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := analyzer.extractDMARCTestMode(tt.record)
|
||||||
|
if tt.wantMode == nil {
|
||||||
|
if result != nil {
|
||||||
|
t.Errorf("extractDMARCTestMode(%q) = %v, want nil", tt.record, *result)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if result == nil {
|
||||||
|
t.Fatalf("extractDMARCTestMode(%q) = nil, want %v", tt.record, *tt.wantMode)
|
||||||
|
}
|
||||||
|
if *result != *tt.wantMode {
|
||||||
|
t.Errorf("extractDMARCTestMode(%q) = %v, want %v", tt.record, *result, *tt.wantMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractDMARCPSD(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
record string
|
||||||
|
wantPSD *string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "psd=y marks Public Suffix Domain",
|
||||||
|
record: "v=DMARC1; p=none; psd=y",
|
||||||
|
wantPSD: utils.PtrTo("y"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "psd=n marks Org Domain boundary",
|
||||||
|
record: "v=DMARC1; p=reject; psd=n",
|
||||||
|
wantPSD: utils.PtrTo("n"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "psd=u is explicit unknown",
|
||||||
|
record: "v=DMARC1; p=quarantine; psd=u",
|
||||||
|
wantPSD: utils.PtrTo("u"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "absent psd tag returns nil",
|
||||||
|
record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
|
||||||
|
wantPSD: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := analyzer.extractDMARCPSD(tt.record)
|
||||||
|
if tt.wantPSD == nil {
|
||||||
|
if result != nil {
|
||||||
|
t.Errorf("extractDMARCPSD(%q) = %v, want nil", tt.record, *result)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if result == nil {
|
||||||
|
t.Fatalf("extractDMARCPSD(%q) = nil, want %q", tt.record, *tt.wantPSD)
|
||||||
|
}
|
||||||
|
if string(*result) != *tt.wantPSD {
|
||||||
|
t.Errorf("extractDMARCPSD(%q) = %q, want %q", tt.record, string(*result), *tt.wantPSD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasDMARCTag(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
record string
|
||||||
|
tag string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "rf tag present", record: "v=DMARC1; p=none; rf=afrf", tag: "rf", want: true},
|
||||||
|
{name: "ri tag present", record: "v=DMARC1; p=none; ri=86400", tag: "ri", want: true},
|
||||||
|
{name: "rf tag absent", record: "v=DMARC1; p=quarantine; rua=mailto:x@example.com", tag: "rf", want: false},
|
||||||
|
{name: "ri tag absent", record: "v=DMARC1; p=quarantine", tag: "ri", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := analyzer.hasDMARCTag(tt.record, tt.tag)
|
||||||
|
if result != tt.want {
|
||||||
|
t.Errorf("hasDMARCTag(%q, %q) = %v, want %v", tt.record, tt.tag, result, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateDMARC(t *testing.T) {
|
func TestValidateDMARC(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -250,13 +395,18 @@ func TestValidateDMARC(t *testing.T) {
|
||||||
record: "v=DMARC1; p=none",
|
record: "v=DMARC1; p=none",
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "DMARCbis: p= absent but rua= present is valid (treated as p=none)",
|
||||||
|
record: "v=DMARC1; rua=mailto:dmarc@example.com",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Invalid DMARC - no version",
|
name: "Invalid DMARC - no version",
|
||||||
record: "p=quarantine",
|
record: "p=quarantine",
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Invalid DMARC - no policy",
|
name: "Invalid DMARC - no policy and no rua",
|
||||||
record: "v=DMARC1",
|
record: "v=DMARC1",
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
const isFallback = $derived(
|
const isFallback = $derived(
|
||||||
!!dmarcRecord?.domain && !!fromDomain && dmarcRecord.domain !== fromDomain,
|
!!dmarcRecord?.domain && !!fromDomain && dmarcRecord.domain !== fromDomain,
|
||||||
);
|
);
|
||||||
|
// A single-label domain (no dot) is a TLD/PSD level fallback
|
||||||
const isPsdFallback = $derived(isFallback && !dmarcRecord?.domain?.includes("."));
|
const isPsdFallback = $derived(isFallback && !dmarcRecord?.domain?.includes("."));
|
||||||
|
|
||||||
// Helper function to determine policy strength
|
// Helper function to determine policy strength
|
||||||
|
|
@ -18,6 +19,15 @@
|
||||||
const strength: Record<string, number> = { none: 0, quarantine: 1, reject: 2 };
|
const strength: Record<string, number> = { none: 0, quarantine: 1, reject: 2 };
|
||||||
return strength[policy || "none"] || 0;
|
return strength[policy || "none"] || 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Effective policy after applying DMARCbis t=y downgrade
|
||||||
|
const effectivePolicy = $derived((): string => {
|
||||||
|
const p = dmarcRecord?.policy ?? "none";
|
||||||
|
if (!dmarcRecord?.test_mode) return p;
|
||||||
|
if (p === "reject") return "quarantine";
|
||||||
|
if (p === "quarantine") return "none";
|
||||||
|
return p;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if dmarcRecord}
|
{#if dmarcRecord}
|
||||||
|
|
@ -68,9 +78,12 @@
|
||||||
No DMARC record exists for <code>{fromDomain}</code>. The record above was
|
No DMARC record exists for <code>{fromDomain}</code>. The record above was
|
||||||
inherited from
|
inherited from
|
||||||
{#if isPsdFallback}
|
{#if isPsdFallback}
|
||||||
the Public Suffix Domain <code>{dmarcRecord.domain}</code> per RFC 9091.
|
the Public Suffix Domain <code>{dmarcRecord.domain}</code> via the DMARCbis
|
||||||
|
DNS Tree Walk (which obsoletes the RFC 9091 PSD DMARC experiment).
|
||||||
{:else}
|
{:else}
|
||||||
the organizational domain <code>{dmarcRecord.domain}</code> per RFC 7489.
|
the organizational domain <code>{dmarcRecord.domain}</code> via the
|
||||||
|
DMARCbis DNS Tree Walk (compatible with RFC 7489 organizational domain
|
||||||
|
fallback).
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -123,6 +136,53 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Test Mode (DMARCbis t= tag) -->
|
||||||
|
{#if dmarcRecord.test_mode}
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Test Mode:</strong>
|
||||||
|
<span class="badge bg-warning">t=y (active)</span>
|
||||||
|
<div class="alert alert-warning mt-2 mb-0 small">
|
||||||
|
<i class="bi bi-flask me-1"></i>
|
||||||
|
<strong>Test mode active</strong> — DMARCbis-compliant receivers will
|
||||||
|
downgrade the effective policy one level:
|
||||||
|
{#if dmarcRecord.policy === "reject"}
|
||||||
|
<code>p=reject</code> is applied as <code>p=quarantine</code>.
|
||||||
|
{:else if dmarcRecord.policy === "quarantine"}
|
||||||
|
<code>p=quarantine</code> is applied as <code>p=none</code> (no action taken).
|
||||||
|
{:else}
|
||||||
|
<code>p=none</code> is unaffected by test mode.
|
||||||
|
{/if}
|
||||||
|
Aggregate reports are still generated normally.
|
||||||
|
This tag replaces the deprecated <code>pct=</code> for gradual rollout.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- PSD tag (DMARCbis psd=) -->
|
||||||
|
{#if dmarcRecord.psd === "y"}
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Public Suffix Domain:</strong>
|
||||||
|
<span class="badge bg-info">psd=y</span>
|
||||||
|
<div class="alert alert-info mt-2 mb-0 small">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
<strong>PSD declared</strong> — this domain is declared as a Public Suffix
|
||||||
|
Domain. DMARCbis-compliant receivers will apply this policy to subdomains
|
||||||
|
that have no DMARC record of their own when using the DNS Tree Walk algorithm.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if dmarcRecord.psd === "n"}
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Organizational Domain Boundary:</strong>
|
||||||
|
<span class="badge bg-info">psd=n</span>
|
||||||
|
<div class="alert alert-info mt-2 mb-0 small">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
<strong>Org Domain declared</strong> — <code>psd=n</code> explicitly declares
|
||||||
|
this as an Organizational Domain boundary. Subdomains with separate DNS
|
||||||
|
delegation will use their own independent DMARCbis Tree Walk.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Subdomain Policy -->
|
<!-- Subdomain Policy -->
|
||||||
{#if dmarcRecord.subdomain_policy}
|
{#if dmarcRecord.subdomain_policy}
|
||||||
{@const mainStrength = policyStrength(dmarcRecord.policy)}
|
{@const mainStrength = policyStrength(dmarcRecord.policy)}
|
||||||
|
|
@ -202,7 +262,7 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Percentage -->
|
<!-- Percentage (pct=, deprecated in DMARCbis) -->
|
||||||
{#if dmarcRecord.percentage !== undefined}
|
{#if dmarcRecord.percentage !== undefined}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<strong>Enforcement Percentage:</strong>
|
<strong>Enforcement Percentage:</strong>
|
||||||
|
|
@ -215,25 +275,35 @@
|
||||||
>
|
>
|
||||||
{dmarcRecord.percentage}%
|
{dmarcRecord.percentage}%
|
||||||
</span>
|
</span>
|
||||||
|
<div class="alert alert-warning mt-2 mb-0 small">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
<strong>Deprecated tag</strong> — the <code>pct=</code> tag is removed in
|
||||||
|
DMARCbis. Many receivers already ignore it. For gradual rollout, replace it
|
||||||
|
with <code>t=y</code> (test mode); for full enforcement, simply remove
|
||||||
|
<code>pct=</code> from your record.
|
||||||
|
{#if dmarcRecord.percentage === 0}
|
||||||
|
<br /><strong>pct=0 is an anti-pattern</strong> — it was widely misused
|
||||||
|
as a signal to bypass DMARC entirely, which is one reason the tag was
|
||||||
|
removed. Use <code>t=y</code> instead.
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{#if dmarcRecord.percentage === 100}
|
{#if dmarcRecord.percentage === 100}
|
||||||
<div class="alert alert-success mt-2 mb-0 small">
|
<div class="alert alert-success mt-2 mb-0 small">
|
||||||
<i class="bi bi-check-circle me-1"></i>
|
<i class="bi bi-check-circle me-1"></i>
|
||||||
<strong>Full enforcement</strong> — all messages are subject to DMARC policy.
|
<strong>Full enforcement</strong> — all messages are subject to DMARC policy.
|
||||||
This provides maximum protection.
|
|
||||||
</div>
|
</div>
|
||||||
{:else if dmarcRecord.percentage >= 50}
|
{:else if dmarcRecord.percentage > 0 && dmarcRecord.percentage >= 50}
|
||||||
<div class="alert alert-warning mt-2 mb-0 small">
|
<div class="alert alert-warning mt-2 mb-0 small">
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
<strong>Partial enforcement</strong> — only {dmarcRecord.percentage}% of
|
<strong>Partial enforcement</strong> — only {dmarcRecord.percentage}% of
|
||||||
messages are subject to DMARC policy. Consider increasing to
|
messages are subject to DMARC policy. Receivers ignoring pct= will apply
|
||||||
<code>pct=100</code> once you've validated your configuration.
|
the full policy regardless.
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else if dmarcRecord.percentage > 0}
|
||||||
<div class="alert alert-danger mt-2 mb-0 small">
|
<div class="alert alert-danger mt-2 mb-0 small">
|
||||||
<i class="bi bi-x-circle me-1"></i>
|
<i class="bi bi-x-circle me-1"></i>
|
||||||
<strong>Low enforcement</strong> — only {dmarcRecord.percentage}% of
|
<strong>Low enforcement</strong> — only {dmarcRecord.percentage}% of
|
||||||
messages are protected. Gradually increase to <code>pct=100</code> for full
|
messages are protected. Receivers ignoring pct= will apply full policy.
|
||||||
protection.
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -319,6 +389,30 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Deprecated rf=/ri= tags -->
|
||||||
|
{#if dmarcRecord.deprecated_rf || dmarcRecord.deprecated_ri}
|
||||||
|
<div class="alert alert-warning mt-2 mb-3 small">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
<strong>Deprecated tags detected</strong> — your record contains
|
||||||
|
{#if dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri}
|
||||||
|
<code>rf=</code> and <code>ri=</code> tags that are
|
||||||
|
{:else if dmarcRecord.deprecated_rf}
|
||||||
|
the <code>rf=</code> tag that is
|
||||||
|
{:else}
|
||||||
|
the <code>ri=</code> tag that is
|
||||||
|
{/if}
|
||||||
|
removed in DMARCbis. Modern receivers will ignore
|
||||||
|
{dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri ? "them" : "it"}.
|
||||||
|
{#if dmarcRecord.deprecated_ri}
|
||||||
|
Aggregate reporting interval is now fixed at ≥ 24 hours regardless of
|
||||||
|
<code>ri=</code>.
|
||||||
|
{/if}
|
||||||
|
You can safely remove
|
||||||
|
{dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri ? "these tags" : "this tag"}
|
||||||
|
from your DMARC record.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error -->
|
||||||
{#if dmarcRecord.error}
|
{#if dmarcRecord.error}
|
||||||
<div class="text-danger">
|
<div class="text-danger">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue