126 lines
3.3 KiB
Go
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)
|
|
}
|