Files
mailgosend/internal/dmarc/dmarc.go
T
2026-05-21 20:27:58 +00:00

126 lines
3.3 KiB
Go

// Package dmarc implements basic DMARC (RFC 7489) policy evaluation.
// Only stdlib net is used for DNS. Supports p=none, p=quarantine, p=reject.
package dmarc
import (
"fmt"
"net"
"strings"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/spf"
)
// Policy represents the DMARC disposition policy.
type Policy int
const (
PolicyNone Policy = iota // p=none
PolicyQuarantine // p=quarantine
PolicyReject // p=reject
)
func (p Policy) String() string {
return [...]string{"none", "quarantine", "reject"}[p]
}
// Result holds the DMARC evaluation outcome.
type Result struct {
Pass bool
Policy Policy
Disposition string // none | quarantine | reject
Reason string
}
// Check evaluates DMARC policy for the given envelope-from domain.
// spfPass: whether SPF passed for this envelope-from domain.
// dkimDomains: set of signing domains that produced a valid DKIM signature.
// Returns a Result and logs no external calls beyond DNS.
func Check(fromDomain string, spfResult spf.Result, dkimDomains []string) *Result {
record, err := fetchDMARC(fromDomain)
if err != nil || record == "" {
// No DMARC record — not a failure, but can't enforce.
return &Result{
Pass: true,
Policy: PolicyNone,
Disposition: "none",
Reason: "no DMARC record for " + fromDomain,
}
}
policy := parsePolicy(record)
// SPF alignment (relaxed): envelope-from domain must equal or be a subdomain
// of the DMARC organisational domain.
orgDomain := orgDomainOf(fromDomain)
spfAligned := spfResult == spf.ResultPass
// DKIM alignment (relaxed): at least one valid DKIM signature domain must
// equal or be a subdomain of the org domain.
dkimAligned := false
for _, d := range dkimDomains {
if strings.EqualFold(d, fromDomain) || strings.HasSuffix(strings.ToLower(d), "."+strings.ToLower(orgDomain)) {
dkimAligned = true
break
}
}
pass := spfAligned || dkimAligned
reason := fmt.Sprintf("SPF=%v DKIM-aligned=%v policy=%s", spfAligned, dkimAligned, policy)
if pass {
return &Result{
Pass: true,
Policy: policy,
Disposition: "none",
Reason: reason,
}
}
return &Result{
Pass: false,
Policy: policy,
Disposition: policy.String(),
Reason: reason,
}
}
// fetchDMARC queries TXT at _dmarc.<domain> and returns the first DMARC record.
func fetchDMARC(domain string) (string, error) {
txts, err := net.LookupTXT("_dmarc." + domain)
if err != nil {
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
return "", nil
}
return "", err
}
for _, txt := range txts {
txt = strings.TrimSpace(txt)
if strings.HasPrefix(txt, "v=DMARC1") {
return txt, nil
}
}
return "", nil
}
// parsePolicy extracts the p= tag from a DMARC record.
func parsePolicy(record string) Policy {
for _, part := range strings.Split(record, ";") {
part = strings.TrimSpace(part)
if strings.HasPrefix(strings.ToLower(part), "p=") {
switch strings.ToLower(part[2:]) {
case "quarantine":
return PolicyQuarantine
case "reject":
return PolicyReject
}
}
}
return PolicyNone
}
// orgDomainOf returns a simplified "organisational domain" — for this
// implementation we use the domain as-is (a full PSL lookup is out of scope).
func orgDomainOf(domain string) string {
return strings.ToLower(domain)
}