DKIM / SPF / DMARC Builder & Checker — Go source
Build the three email-authentication DNS records — SPF, DKIM, and DMARC — as ready-to-paste TXT values, or look up a domain's live records over DNS-over-HTTPS. Import existing records, catch publishing mistakes, share via URL. Entirely client-side.
This is the Go implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Package dkimspfdmarc is the Go twin of CosmoDev's src/lib/dkim-spf-dmarc.ts
// (dual source: the web lib is TypeScript, the CLI lib is Go — kept in
// lock-step). Pure + deterministic, never panics. The table-driven tests in
// dkim-spf-dmarc_test.go share vectors with src/lib/dkim-spf-dmarc.test.ts so
// the two implementations are held to the same contract.
//
// This twin ports the lib's PURE surface only: domain/selector helpers, the
// SPF / DKIM / DMARC record parsers (RFC 7208 / 6376 / 7489), the zone-file
// record builder + validator + paste-importer, and the shareable-URL codecs.
// The DoH network lookups (checkSPF / checkDKIM / checkDMARC in the TS lib)
// stay OUT — the twin works on record STRINGS, exactly like the TS pure
// surface.
//
// Optional-field mapping (TS → Go): `undefined`-able string fields become ""
// ("absent" conflates with "" where the TS value space never uses ""); p= and
// pct= become *string / *int so present-vs-absent stays distinguishable;
// number|null percent is *int (nil = null).
package dkimspfdmarc
import (
"encoding/base64"
"fmt"
"math"
"net/url"
"regexp"
"strconv"
"strings"
"unicode"
)
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
// SPFQualifier is one of '+', '-', '~', '?'.
type SPFQualifier = string
// SPFMechanism is one parsed SPF term (qualifier + kind + optional value).
type SPFMechanism struct {
Qualifier SPFQualifier
Kind string // all|include|a|mx|ip4|ip6|exists|ptr
// Value is the domain/IP/CIDR argument after the colon; nil when the term
// carried no ":value" part.
Value *string
}
// SPFRecord is the result of parsing one or more (newline-joined) TXT strings
// for SPF.
type SPFRecord struct {
Valid bool
Version string
Mechanisms []SPFMechanism
Redirect string
Exp string
LookupCount int // mechanisms that cost a DNS query under RFC 7208 §4.6.4 (limit 10)
RecordCount int // number of v=spf1 records found (>1 is a hard error for receivers)
Warnings []string
}
// DKIMRecord is the result of parsing a `<selector>._domainkey` TXT record.
type DKIMRecord struct {
Valid bool
Version string // upper-cased v= tag; "" when absent
KeyType string
PublicKey *string // base64, whitespace-stripped; nil until successfully read
KeyBits *int // approximate modulus size (RSA only; derived from DER byte length)
Hashes []string
Services []string
Flags []string
Warnings []string
}
// DMARCRecord is the result of parsing a `_dmarc` TXT record.
type DMARCRecord struct {
Valid bool
Policy string // none|quarantine|reject; "" until validated
SubdomainPolicy string
AggregateURIs []string
ForensicURIs []string
Percent *int
DKIMAlignment string // "" absent, else "r"|"s"
SPFAlignment string // "" absent, else "r"|"s"
Warnings []string
}
// ---------------------------------------------------------------------------
// Shared helpers (mirror normalizeDomain / isDomainLike / isValidSelector)
// ---------------------------------------------------------------------------
var (
schemeRe = regexp.MustCompile(`(?i)^[a-z][a-z0-9+.-]*://`)
mailtoRe = regexp.MustCompile(`(?i)^mailto:`)
trailingDots = regexp.MustCompile(`\.+$`)
domainRe = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$`)
selectorRe = regexp.MustCompile(`(?i)^[a-z0-9][a-z0-9._-]*$`)
quotedRe = regexp.MustCompile(`^"(.*)"$`)
whitespaceRe = regexp.MustCompile(`\s+`)
spfLineRe = regexp.MustCompile(`(?i)^v=spf1(?:\s|$)`)
spfModifierRe = regexp.MustCompile(`(?i)^([a-z][a-z0-9-]*)=(.*)$`)
spfMechRe = regexp.MustCompile(`(?i)^([+\-~?])?([a-z0-9]+)(?::(.*))?$`)
spfPayloadRe = regexp.MustCompile(`(?i)v=spf1(\s|$)`)
dkimPayloadRe = regexp.MustCompile(`(?i)v=DKIM`)
dmarcPayRe = regexp.MustCompile(`(?i)v=DMARC`)
spfTokenRe = regexp.MustCompile(`(?i)^v=spf1$`)
spfLeadRe = regexp.MustCompile(`(?i)^\s*v=spf1\s*`)
)
// NormalizeDomain strips a leading scheme, userinfo, path, query, port, and
// trailing dot(s) from user input, returning the bare lowercase host.
func NormalizeDomain(raw string) string {
s := strings.TrimSpace(raw)
s = schemeRe.ReplaceAllString(s, "")
s = mailtoRe.ReplaceAllString(s, "")
if strings.Contains(s, "@") {
s = s[strings.LastIndex(s, "@")+1:]
}
s = strings.Split(s, "/")[0]
s = strings.Split(s, "?")[0]
s = strings.Split(s, ":")[0]
s = trailingDots.ReplaceAllString(s, "")
return strings.ToLower(s)
}
// IsDomainLike reports whether the string looks like a plausible multi-label
// domain (example.com).
func IsDomainLike(domain string) bool {
return domainRe.MatchString(domain) && len(domain) <= 253
}
// IsValidSelector reports whether the selector is a safe single DNS label
// chain (no spaces, no traversal).
func IsValidSelector(selector string) bool {
s := strings.TrimSpace(selector)
return len(s) > 0 && len(s) <= 100 && selectorRe.MatchString(s) && !strings.Contains(s, "..")
}
// ---------------------------------------------------------------------------
// SPF — RFC 7208 (mirrors parseSPF)
// ---------------------------------------------------------------------------
var spfLookupKinds = map[string]bool{
"include": true, "a": true, "mx": true, "exists": true, "ptr": true,
}
var spfKnownKinds = map[string]bool{
"all": true, "include": true, "a": true, "mx": true,
"ip4": true, "ip6": true, "exists": true, "ptr": true,
}
// ParseSPF parses one or more (newline-joined) TXT record strings for SPF.
func ParseSPF(txt string) SPFRecord {
var lines []string
for _, l := range strings.Split(txt, "\n") {
t := strings.TrimSpace(l)
t = quotedRe.ReplaceAllString(t, "$1")
if t != "" {
lines = append(lines, t)
}
}
var spfLines []string
for _, l := range lines {
if spfLineRe.MatchString(l) {
spfLines = append(spfLines, l)
}
}
warnings := []string{}
if len(spfLines) == 0 {
return SPFRecord{
Valid: false,
Mechanisms: []SPFMechanism{},
Warnings: []string{"No v=spf1 record found in the supplied text."},
}
}
if len(spfLines) > 1 {
warnings = append(warnings, fmt.Sprintf("%d SPF records found — RFC 7208 allows exactly one. Receivers treat this as a permanent error and ignore SPF for the whole domain.", len(spfLines)))
}
terms := strings.Fields(spfLines[0])
version := terms[0]
mechanisms := []SPFMechanism{}
redirect := ""
exp := ""
for _, term := range terms[1:] {
// Modifiers use '=': redirect= and exp=
if m := spfModifierRe.FindStringSubmatch(term); m != nil {
name := strings.ToLower(m[1])
if name == "redirect" {
redirect = m[2]
} else if name == "exp" {
exp = m[2]
} else {
warnings = append(warnings, fmt.Sprintf("Unknown modifier %q ignored.", term))
}
continue
}
idx := spfMechRe.FindStringSubmatchIndex(term)
if idx == nil {
warnings = append(warnings, fmt.Sprintf("Unrecognized term %q ignored.", term))
continue
}
qualifier := "+"
if idx[2] >= 0 {
qualifier = term[idx[2]:idx[3]]
}
kind := strings.ToLower(term[idx[4]:idx[5]])
var value *string
if idx[6] >= 0 {
v := term[idx[6]:idx[7]]
value = &v
}
if !spfKnownKinds[kind] {
warnings = append(warnings, fmt.Sprintf("Unknown mechanism %q ignored.", term))
continue
}
if (value == nil || *value == "") && (kind == "include" || kind == "exists") {
warnings = append(warnings, fmt.Sprintf("Mechanism %q is missing its required value.", term))
continue
}
mechanisms = append(mechanisms, SPFMechanism{Qualifier: qualifier, Kind: kind, Value: value})
}
lookupCount := 0
for _, mech := range mechanisms {
if spfLookupKinds[mech.Kind] {
lookupCount++
}
}
if redirect != "" {
lookupCount++
}
var all *SPFMechanism
for i := range mechanisms {
if mechanisms[i].Kind == "all" {
all = &mechanisms[i]
break
}
}
hasPtr := false
for _, mech := range mechanisms {
if mech.Kind == "ptr" {
hasPtr = true
break
}
}
if all == nil && redirect == "" {
warnings = append(warnings, `No "all" mechanism and no "redirect=" — unmatched senders get a Neutral result, so anyone can still send mail that looks like this domain.`)
}
if all != nil && all.Qualifier == "+" {
warnings = append(warnings, `"+all" explicitly allows every host on the internet to send mail as this domain — this defeats SPF entirely.`)
} else if all != nil && all.Qualifier == "?" {
warnings = append(warnings, `"?all" (Neutral) lets unmatched senders through with no protection. Prefer "~all" or "-all".`)
}
if hasPtr {
warnings = append(warnings, `The "ptr" mechanism is deprecated (RFC 7208 §5.5) and should not be used.`)
}
if redirect != "" && all != nil {
warnings = append(warnings, `A "redirect=" modifier is ignored when an "all" mechanism is present.`)
}
if lookupCount > 10 {
warnings = append(warnings, fmt.Sprintf("%d DNS-lookup mechanisms — RFC 7208 §4.6.4 caps SPF at 10. Receivers that hit the cap return permerror and ignore the record.", lookupCount))
}
return SPFRecord{
Valid: true,
Version: version,
Mechanisms: mechanisms,
Redirect: redirect,
Exp: exp,
LookupCount: lookupCount,
RecordCount: len(spfLines),
Warnings: warnings,
}
}
// ---------------------------------------------------------------------------
// DKIM — RFC 6376 (mirrors parseDKIM)
// ---------------------------------------------------------------------------
// decodeBase64Lenient decodes base64 to bytes without failing the caller on
// bad input (returns ok=false instead). Callers pass trimmed, non-empty values
// (empty p= is handled before this).
func decodeBase64Lenient(b64 string) ([]byte, bool) {
clean := whitespaceRe.ReplaceAllString(b64, "")
padded := clean + strings.Repeat("=", (4-len(clean)%4)%4)
dst, err := base64.StdEncoding.DecodeString(padded)
if err != nil {
return nil, false
}
return dst, true
}
// parseTags splits a semicolon-separated tag list into a lowercase-key map,
// mirroring the shared loop in parseDKIM / parseDMARC. Malformed terms
// (no '=' or empty key) produce warnings and are skipped.
func parseTags(txt string, warnings *[]string) map[string]string {
tags := map[string]string{}
for _, part := range strings.Split(txt, ";") {
term := strings.TrimSpace(quotedRe.ReplaceAllString(strings.TrimSpace(part), "$1"))
if term == "" {
continue
}
eq := strings.Index(term, "=")
if eq <= 0 {
*warnings = append(*warnings, fmt.Sprintf("Malformed tag %q ignored.", term))
continue
}
tags[strings.ToLower(strings.TrimSpace(term[:eq]))] = strings.TrimSpace(term[eq+1:])
}
return tags
}
// splitColonList splits a ':'-separated tag value, trimmed and empty-removed.
func splitColonList(v string) []string {
parts := strings.Split(v, ":")
out := make([]string, 0, len(parts))
for _, s := range parts {
if t := strings.TrimSpace(s); t != "" {
out = append(out, t)
}
}
return out
}
// ParseDKIM parses a `<selector>._domainkey` TXT record.
func ParseDKIM(txt string) DKIMRecord {
warnings := []string{}
tags := parseTags(txt, &warnings)
version, hasVersion := tags["v"]
if hasVersion && version != "" && strings.ToUpper(version) != "DKIM1" {
warnings = append(warnings, fmt.Sprintf("Unusual version tag v=%s (expected DKIM1).", version))
}
keyType := "rsa"
if k, ok := tags["k"]; ok {
keyType = k
}
p, hasP := tags["p"]
hashes := splitColonList(tags["h"])
services := splitColonList(tags["s"])
flags := splitColonList(tags["t"])
record := DKIMRecord{
Valid: true,
KeyType: keyType,
Hashes: hashes,
Services: services,
Flags: flags,
Warnings: warnings,
}
if hasVersion && version != "" {
record.Version = strings.ToUpper(version)
}
if !hasP {
record.Valid = false
record.Warnings = append(record.Warnings, "No p= tag — this record is not a usable DKIM key.")
return record
}
if p == "" {
record.Warnings = append(record.Warnings, "p= is empty — the key is revoked. Receivers will treat mail signed with this selector as unsigned.")
return record
}
decoded, ok := decodeBase64Lenient(p)
if !ok {
record.Warnings = append(record.Warnings, "The p= value is not valid base64 — the key could not be read.")
return record
}
publicKey := whitespaceRe.ReplaceAllString(p, "")
record.PublicKey = &publicKey
if keyType == "rsa" {
// SubjectPublicKeyInfo DER ≈ modulus bits/8 + ~24 bytes of ASN.1 overhead.
// The estimate is close enough to classify 512/1024/2048/4096-bit keys.
bits := max(0, len(decoded)-24) * 8
record.KeyBits = &bits
if bits < 1024 {
record.Warnings = append(record.Warnings, fmt.Sprintf("Weak RSA key (~%d bits). Keys under 1024 bits are considered breakable; RFC 8301 discourages short keys.", bits))
} else if bits < 2048 {
record.Warnings = append(record.Warnings, fmt.Sprintf("RSA key of ~%d bits works today but is below the recommended 2048 bits (RFC 8301).", bits))
}
}
hasFlag := func(f string) bool {
for _, x := range flags {
if x == f {
return true
}
}
return false
}
if hasFlag("y") {
record.Warnings = append(record.Warnings, "t=y — the key is in test mode: receivers must treat signatures as if unsigned.")
}
if hasFlag("s") {
record.Warnings = append(record.Warnings, "t=s — strict domain matching: the key cannot be used for subdomain signatures (informational).")
}
return record
}
// ---------------------------------------------------------------------------
// DMARC — RFC 7489 (mirrors parseDMARC)
// ---------------------------------------------------------------------------
var dmarcPolicies = map[string]bool{"none": true, "quarantine": true, "reject": true}
// tsNumber mirrors JS Number() for the pct= tag: '' → 0; numeric strings parse;
// anything else is not a number.
func tsNumber(s string) (float64, bool) {
if s == "" {
return 0, true
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, false
}
return f, true
}
// ParseDMARC parses a `_dmarc` TXT record.
func ParseDMARC(txt string) DMARCRecord {
warnings := []string{}
tags := parseTags(txt, &warnings)
record := DMARCRecord{
Valid: true,
AggregateURIs: []string{},
ForensicURIs: []string{},
Warnings: warnings,
}
version, hasVersion := tags["v"]
if !hasVersion || version == "" {
record.Valid = false
record.Warnings = append(record.Warnings, "No v= tag — this is not a DMARC record.")
return record
}
if strings.ToUpper(version) != "DMARC1" {
record.Valid = false
record.Warnings = append(record.Warnings, fmt.Sprintf("Unknown version v=%s (expected DMARC1).", version))
return record
}
p, hasP := tags["p"]
policy := strings.ToLower(p)
if !hasP || policy == "" {
record.Valid = false
record.Warnings = append(record.Warnings, "No p= policy tag — DMARC requires it.")
return record
}
if !dmarcPolicies[policy] {
record.Valid = false
record.Warnings = append(record.Warnings, fmt.Sprintf("Invalid policy p=%s (expected none, quarantine, or reject).", policy))
return record
}
record.Policy = policy
if sp, ok := tags["sp"]; ok && sp != "" {
lower := strings.ToLower(sp)
if dmarcPolicies[lower] {
record.SubdomainPolicy = lower
} else {
record.Warnings = append(record.Warnings, fmt.Sprintf("Invalid sp=%s ignored (expected none, quarantine, or reject).", lower))
}
}
if rua, ok := tags["rua"]; ok && rua != "" {
record.AggregateURIs = splitCommaList(rua)
}
if ruf, ok := tags["ruf"]; ok && ruf != "" {
record.ForensicURIs = splitCommaList(ruf)
}
if pct, ok := tags["pct"]; ok {
if n, isNum := tsNumber(pct); !isNum || n != math.Trunc(n) || n < 0 || n > 100 {
record.Warnings = append(record.Warnings, fmt.Sprintf("Invalid pct=%s ignored (must be 0-100).", pct))
} else {
percent := int(n)
record.Percent = &percent
}
}
if adkim, ok := tags["adkim"]; ok && adkim != "" {
if adkim == "r" || adkim == "s" {
record.DKIMAlignment = adkim
} else {
record.Warnings = append(record.Warnings, fmt.Sprintf("Invalid adkim=%s ignored (expected r or s).", adkim))
}
}
if aspf, ok := tags["aspf"]; ok && aspf != "" {
if aspf == "r" || aspf == "s" {
record.SPFAlignment = aspf
} else {
record.Warnings = append(record.Warnings, fmt.Sprintf("Invalid aspf=%s ignored (expected r or s).", aspf))
}
}
// Policy guidance
if record.Policy == "none" {
record.Warnings = append(record.Warnings, "p=none is monitor-only — no mail is quarantined or rejected, but you still need SPF/DKIM to pass for reports to be useful.")
}
if len(record.AggregateURIs) == 0 {
record.Warnings = append(record.Warnings, "No rua= address — without aggregate reports you cannot see who is failing authentication. Add rua=mailto:reports@example.com.")
} else if len(record.ForensicURIs) > 0 {
record.Warnings = append(record.Warnings, "ruf= (forensic reports) is supported by few receivers and may leak message content to the report address (informational).")
}
if record.Percent != nil && *record.Percent < 100 && record.Policy != "none" {
pct := *record.Percent
record.Warnings = append(record.Warnings, fmt.Sprintf("pct=%d applies the policy to only %d%% of mail — the other %d%% is unaffected.", pct, pct, 100-pct))
}
return record
}
// splitCommaList splits a ','-separated tag value, trimmed and empty-removed.
func splitCommaList(v string) []string {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, s := range parts {
if t := strings.TrimSpace(s); t != "" {
out = append(out, t)
}
}
return out
}
// ---------------------------------------------------------------------------
// Record builder (mirrors SpfPart / DkimPart / DmarcPart / DnsRecordsConfig)
// ---------------------------------------------------------------------------
// SpfPart is the builder state for the SPF record.
type SpfPart struct {
Enabled bool
// Terms are the raw terms after "v=spf1" — mechanisms and modifiers,
// e.g. ["include:_spf.google.com", "~all"].
Terms []string
}
// DkimPart is the builder state for the DKIM record.
type DkimPart struct {
Enabled bool
KeyType string // k= tag — "rsa" or "ed25519"
PublicKey string // p= tag — the base64 public key from the mail provider
Hashes []string // h= tag values, e.g. ["sha256"]
TestMode bool // t=y test mode
}
// DmarcPart is the builder state for the DMARC record.
type DmarcPart struct {
Enabled bool
Policy string // p= tag. "" means not chosen yet.
SubdomainPolicy string // sp= tag. "" omits it (subdomains inherit p=).
Rua []string // rua= aggregate report URIs
Ruf []string // ruf= forensic report URIs
Percent *int // pct= tag; nil omits it
DKIMAlignment string // "r" or "s"
SPFAlignment string // "r" or "s"
}
// DnsRecordsConfig is the full builder state for all three records.
type DnsRecordsConfig struct {
Domain string
Selector string
DKIM DkimPart
SPF SpfPart
DMARC DmarcPart
}
// DnsIssue is one builder-validation finding.
type DnsIssue struct {
Code string // selector-missing|spf-multiple-records|spf-all-missing|dmarc-policy-missing|domain-not-fqdn
Severity string // warn|info
Value *string
}
// ParsedDnsRecords is the result of importing pasted TXT records.
type ParsedDnsRecords struct {
Config DnsRecordsConfig
Found struct {
SPF bool
DKIM bool
DMARC bool
}
}
// DefaultDnsRecordsConfig returns fresh builder state: all three records on,
// Google-Workspace-shaped defaults.
func DefaultDnsRecordsConfig() DnsRecordsConfig {
return DnsRecordsConfig{
Domain: "example.com",
Selector: "google",
SPF: SpfPart{Enabled: true, Terms: []string{"include:_spf.google.com", "~all"}},
DKIM: DkimPart{Enabled: true, KeyType: "rsa", PublicKey: "", Hashes: []string{"sha256"}, TestMode: false},
DMARC: DmarcPart{
Enabled: true,
Policy: "none",
Rua: []string{"mailto:dmarc@example.com"},
Ruf: []string{},
Percent: nil,
DKIMAlignment: "r",
SPFAlignment: "r",
},
}
}
// cloneStrings copies a slice preserving the nil-vs-empty distinction (the TS
// lib deep-clones via JSON, where both survive as "array or absent").
func cloneStrings(s []string) []string {
if s == nil {
return nil
}
out := make([]string, len(s))
copy(out, s)
return out
}
func cloneConfig(c DnsRecordsConfig) DnsRecordsConfig {
out := c
out.SPF.Terms = cloneStrings(c.SPF.Terms)
out.DKIM.Hashes = cloneStrings(c.DKIM.Hashes)
out.DMARC.Rua = cloneStrings(c.DMARC.Rua)
out.DMARC.Ruf = cloneStrings(c.DMARC.Ruf)
if c.DMARC.Percent != nil {
pct := *c.DMARC.Percent
out.DMARC.Percent = &pct
}
return out
}
// SpfValue renders the SPF TXT value ("v=spf1 include:… ~all").
func SpfValue(part SpfPart) string {
terms := make([]string, 0, len(part.Terms))
for _, t := range part.Terms {
if trimmed := strings.TrimSpace(t); trimmed != "" {
terms = append(terms, trimmed)
}
}
return strings.TrimRight("v=spf1 "+strings.Join(terms, " "), " \t\n\r")
}
// DkimValue renders the DKIM TXT value ("v=DKIM1; k=rsa; p=…").
func DkimValue(part DkimPart) string {
keyType := strings.TrimSpace(part.KeyType)
if keyType == "" {
keyType = "rsa"
}
tags := []string{"v=DKIM1", "k=" + keyType}
hashes := make([]string, 0, len(part.Hashes))
for _, h := range part.Hashes {
if trimmed := strings.TrimSpace(h); trimmed != "" {
hashes = append(hashes, trimmed)
}
}
if len(hashes) > 0 {
tags = append(tags, "h="+strings.Join(hashes, ":"))
}
if part.TestMode {
tags = append(tags, "t=y")
}
tags = append(tags, "p="+whitespaceRe.ReplaceAllString(part.PublicKey, ""))
return strings.Join(tags, "; ")
}
// DmarcValue renders the DMARC TXT value ("v=DMARC1; p=…; rua=…").
func DmarcValue(part DmarcPart) string {
policy := part.Policy
if policy == "" {
policy = "none"
}
tags := []string{"v=DMARC1", "p=" + policy}
if part.SubdomainPolicy != "" {
tags = append(tags, "sp="+part.SubdomainPolicy)
}
if len(part.Rua) > 0 {
tags = append(tags, "rua="+strings.Join(part.Rua, ","))
}
if len(part.Ruf) > 0 {
tags = append(tags, "ruf="+strings.Join(part.Ruf, ","))
}
if part.Percent != nil {
tags = append(tags, "pct="+strconv.Itoa(*part.Percent))
}
tags = append(tags, "adkim="+part.DKIMAlignment, "aspf="+part.SPFAlignment)
return strings.Join(tags, "; ")
}
// GenerateDnsRecords renders zone-file style output: every enabled record
// preceded by a comment naming the TXT record's host, so the whole block can
// be pasted at a provider. Never panics.
func GenerateDnsRecords(config DnsRecordsConfig) string {
domain := NormalizeDomain(config.Domain)
if domain == "" {
domain = "example.com"
}
selector := strings.ToLower(strings.TrimSpace(config.Selector))
if selector == "" {
selector = "default"
}
out := []string{
fmt.Sprintf("; Email authentication (SPF / DKIM / DMARC) records for %s", domain),
"; Publish each block as a TXT record at your DNS provider.",
"",
}
if config.SPF.Enabled {
out = append(out, fmt.Sprintf("; SPF — TXT at %s (the zone apex; often written \"@\")", domain), fmt.Sprintf("%s. IN TXT \"%s\"", domain, SpfValue(config.SPF)), "")
}
if config.DKIM.Enabled {
out = append(out, fmt.Sprintf("; DKIM — TXT at %s._domainkey.%s", selector, domain), fmt.Sprintf("%s._domainkey.%s. IN TXT \"%s\"", selector, domain, DkimValue(config.DKIM)), "")
}
if config.DMARC.Enabled {
out = append(out, fmt.Sprintf("; DMARC — TXT at _dmarc.%s", domain), fmt.Sprintf("_dmarc.%s. IN TXT \"%s\"", domain, DmarcValue(config.DMARC)), "")
}
return strings.TrimRight(strings.Join(out, "\n"), " \t\n\r") + "\n"
}
// ValidateDnsRecords reports the five classic publishing mistakes.
func ValidateDnsRecords(config DnsRecordsConfig) []DnsIssue {
issues := []DnsIssue{}
domain := strings.TrimSpace(config.Domain)
if domain != "" && !IsDomainLike(NormalizeDomain(domain)) {
v := domain
issues = append(issues, DnsIssue{Code: "domain-not-fqdn", Severity: "warn", Value: &v})
}
if config.SPF.Enabled {
// More than one "v=spf1" token means more than one SPF record was
// pasted in — receivers treat that as a permanent error.
spfTokens := 0
for _, t := range strings.Fields(SpfValue(config.SPF)) {
if spfTokenRe.MatchString(t) {
spfTokens++
}
}
if spfTokens > 1 {
v := strconv.Itoa(spfTokens)
issues = append(issues, DnsIssue{Code: "spf-multiple-records", Severity: "warn", Value: &v})
}
rec := ParseSPF(SpfValue(config.SPF))
hasAll := false
for _, m := range rec.Mechanisms {
if m.Kind == "all" {
hasAll = true
break
}
}
if !hasAll && rec.Redirect == "" {
issues = append(issues, DnsIssue{Code: "spf-all-missing", Severity: "warn"})
}
}
if config.DKIM.Enabled && strings.TrimSpace(config.Selector) == "" {
issues = append(issues, DnsIssue{Code: "selector-missing", Severity: "warn"})
}
if config.DMARC.Enabled && config.DMARC.Policy == "" {
issues = append(issues, DnsIssue{Code: "dmarc-policy-missing", Severity: "warn"})
}
return issues
}
// txtPayload strips a zone-file prefix (`name IN TXT "…"`) down to the record
// value itself.
func txtPayload(line string) string {
q := strings.Index(line, `"`)
if q == -1 {
return strings.TrimSpace(line)
}
end := strings.LastIndex(line, `"`)
if end > q {
return strings.TrimSpace(line[q+1 : end])
}
return strings.TrimSpace(line[q+1:])
}
// recordName returns the record host of a zone line
// ("_dmarc.example.com." → "_dmarc.example.com"); "" when the line is a bare
// value.
func recordName(line string) string {
q := strings.Index(line, `"`)
head := strings.TrimSpace(line)
if q != -1 {
head = strings.TrimSpace(line[:q])
}
if strings.IndexFunc(head, unicode.IsSpace) < 0 {
return ""
}
return trailingDots.ReplaceAllString(strings.Fields(head)[0], "")
}
// ParseDnsRecords parses pasted TXT record values (bare values or full zone
// lines) into builder state. Parts not present keep their `prev` values.
// Never panics.
func ParseDnsRecords(text string, prev DnsRecordsConfig) ParsedDnsRecords {
config := cloneConfig(prev)
var found struct {
SPF bool
DKIM bool
DMARC bool
}
var spfLines []string
spfName := ""
dkimPayload, dkimName := "", ""
dmarcPayload, dmarcName := "", ""
for _, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
payload := txtPayload(line)
name := recordName(line)
switch {
case spfPayloadRe.MatchString(payload):
spfLines = append(spfLines, payload)
if spfName == "" {
spfName = name
}
case dkimPayloadRe.MatchString(payload) || strings.Contains(name, "._domainkey"):
dkimPayload = payload
dkimName = name
case dmarcPayRe.MatchString(payload) || strings.HasPrefix(name, "_dmarc."):
dmarcPayload = payload
dmarcName = name
}
}
// Sniff the record host for domain and selector: an explicit name wins.
dmarcDomain := ""
if strings.HasPrefix(dmarcName, "_dmarc.") {
dmarcDomain = dmarcName[len("_dmarc."):]
}
dkimAt := strings.Index(dkimName, "._domainkey.")
sniffDomain := dmarcDomain
if sniffDomain == "" && dkimAt > 0 {
sniffDomain = dkimName[dkimAt+len("._domainkey."):]
}
if sniffDomain == "" {
sniffDomain = spfName
}
if sniffDomain != "" && IsDomainLike(sniffDomain) {
config.Domain = sniffDomain
}
if dkimAt > 0 {
config.Selector = dkimName[:dkimAt]
}
if len(spfLines) > 0 {
// First record's terms are unpacked; any further whole records stay
// as terms so ValidateDnsRecords can flag them.
terms := []string{}
for i, l := range spfLines {
if i == 0 {
for _, t := range strings.Fields(spfLeadRe.ReplaceAllString(l, "")) {
if trimmed := strings.TrimSpace(t); trimmed != "" {
terms = append(terms, trimmed)
}
}
} else {
terms = append(terms, strings.TrimSpace(l))
}
}
config.SPF = SpfPart{Enabled: true, Terms: terms}
found.SPF = true
}
if dkimPayload != "" {
rec := ParseDKIM(dkimPayload)
if rec.Valid {
publicKey := ""
if rec.PublicKey != nil {
publicKey = *rec.PublicKey
}
testMode := false
for _, f := range rec.Flags {
if f == "y" {
testMode = true
break
}
}
config.DKIM = DkimPart{
Enabled: true,
KeyType: rec.KeyType,
PublicKey: publicKey,
Hashes: rec.Hashes,
TestMode: testMode,
}
found.DKIM = true
}
}
if dmarcPayload != "" {
rec := ParseDMARC(dmarcPayload)
if rec.Valid {
// ParseDMARC only reports Valid=true once p= is set — policy is
// defined.
config.DMARC = DmarcPart{
Enabled: true,
Policy: rec.Policy,
SubdomainPolicy: rec.SubdomainPolicy,
Rua: rec.AggregateURIs,
Ruf: rec.ForensicURIs,
Percent: rec.Percent,
DKIMAlignment: rec.DKIMAlignment,
SPFAlignment: rec.SPFAlignment,
}
if config.DMARC.DKIMAlignment == "" {
config.DMARC.DKIMAlignment = "r"
}
if config.DMARC.SPFAlignment == "" {
config.DMARC.SPFAlignment = "r"
}
found.DMARC = true
}
}
return ParsedDnsRecords{Config: config, Found: found}
}
// ---------------------------------------------------------------------------
// URL codecs for shareable state (mirrors toQuery / fromQuery)
// ---------------------------------------------------------------------------
// enc mirrors encodeURIComponent: percent-encodes everything outside
// A-Za-z0-9 - _ . ! ~ * ' ( ) as UTF-8 bytes.
func enc(s string) string {
var b strings.Builder
for _, r := range s {
if isURINeverEncoded(r) {
b.WriteRune(r)
continue
}
for _, c := range []byte(string(r)) {
fmt.Fprintf(&b, "%%%02X", c)
}
}
return b.String()
}
func isURINeverEncoded(r rune) bool {
switch {
case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9':
return true
}
switch r {
case '-', '_', '.', '!', '~', '*', '\'', '(', ')':
return true
}
return false
}
// dec mirrors the TS dec(): decodeURIComponent with a raw fallback on
// malformed input rather than an error.
func dec(s string) string {
if d, err := url.PathUnescape(s); err == nil {
return d
}
return s
}
// formEncode mirrors the URLSearchParams serializer: '+' for space; only
// * - . 0-9 A-Z _ a-z pass through; everything else percent-encoded.
func formEncode(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r == ' ':
b.WriteByte('+')
case r == '*' || r == '-' || r == '.' || r == '_' ||
(r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z'):
b.WriteRune(r)
default:
for _, c := range []byte(string(r)) {
fmt.Fprintf(&b, "%%%02X", c)
}
}
}
return b.String()
}
// parseParamsLenient mirrors URLSearchParams over a query string: split on
// '&', split each pair at the first '=', decode with '+'→space, and KEEP the
// raw text of a malformed escape instead of failing the whole parse. First
// occurrence of a key wins (TS params.get semantics).
func parseParamsLenient(query string) map[string]string {
m := map[string]string{}
for _, pair := range strings.Split(query, "&") {
if pair == "" {
continue
}
k, v := pair, ""
if eq := strings.Index(pair, "="); eq >= 0 {
k, v = pair[:eq], pair[eq+1:]
}
dk, err := url.QueryUnescape(k)
if err != nil {
dk = k
}
dv, err := url.QueryUnescape(v)
if err != nil {
dv = v
}
if _, exists := m[dk]; !exists {
m[dk] = dv
}
}
return m
}
// sections splits "k=rsa|h=sha256|…" into a tag → value map. Mirrors the TS
// helper's slice semantics for a section without '=' (key drops its last
// character, which becomes the value).
func sections(param string) map[string]string {
m := map[string]string{}
for _, s := range strings.Split(param, "|") {
eq := strings.Index(s, "=")
if eq < 0 {
if len(s) == 0 {
m[""] = ""
continue
}
m[s[:len(s)-1]] = s[len(s)-1:]
continue
}
m[s[:eq]] = s[eq+1:]
}
return m
}
var policyValues = map[string]bool{"none": true, "quarantine": true, "reject": true}
// ToQuery encodes the builder state as a URL query string, mirroring toQuery:
// components are enc()'d BEFORE the compact format is assembled, so the
// '|'/','/'=' delimiters can never appear inside a component after decoding.
// Param presence (spf/dkim/dm) carries the enabled flags; absent sections
// fall back to defaults. Key order is d, sel, spf, dkim, dm — the same
// insertion order URLSearchParams emits.
func ToQuery(config DnsRecordsConfig) string {
var parts []string
if strings.TrimSpace(config.Domain) != "" {
parts = append(parts, "d="+formEncode(enc(strings.TrimSpace(config.Domain))))
}
if strings.TrimSpace(config.Selector) != "" {
parts = append(parts, "sel="+formEncode(enc(strings.TrimSpace(config.Selector))))
}
if config.SPF.Enabled {
terms := make([]string, 0, len(config.SPF.Terms))
for _, t := range config.SPF.Terms {
terms = append(terms, enc(strings.TrimSpace(t)))
}
parts = append(parts, "spf="+formEncode(strings.Join(terms, ",")))
}
if config.DKIM.Enabled {
hashes := make([]string, 0, len(config.DKIM.Hashes))
for _, h := range config.DKIM.Hashes {
hashes = append(hashes, enc(h))
}
secs := []string{
"k=" + enc(config.DKIM.KeyType),
"h=" + strings.Join(hashes, ","),
"t=0",
"p=" + enc(config.DKIM.PublicKey),
}
if config.DKIM.TestMode {
secs[2] = "t=1"
}
parts = append(parts, "dkim="+formEncode(strings.Join(secs, "|")))
}
if config.DMARC.Enabled {
pct := ""
if config.DMARC.Percent != nil {
pct = strconv.Itoa(*config.DMARC.Percent)
}
secs := []string{
"p=" + config.DMARC.Policy,
"sp=" + config.DMARC.SubdomainPolicy,
"rua=" + joinEncoded(config.DMARC.Rua),
"ruf=" + joinEncoded(config.DMARC.Ruf),
"pct=" + pct,
"adkim=" + config.DMARC.DKIMAlignment,
"aspf=" + config.DMARC.SPFAlignment,
}
parts = append(parts, "dm="+formEncode(strings.Join(secs, "|")))
}
return strings.Join(parts, "&")
}
func joinEncoded(list []string) string {
out := make([]string, 0, len(list))
for _, s := range list {
out = append(out, enc(s))
}
return strings.Join(out, ",")
}
// FromQuery decodes a ToQuery-style query string back into builder state,
// mirroring fromQuery. Returns nil when none of d/sel/spf/dkim/dm is present.
func FromQuery(query string) *DnsRecordsConfig {
params := parseParamsLenient(query)
if _, ok := params["d"]; !ok {
if _, ok := params["sel"]; !ok {
if _, ok := params["spf"]; !ok {
if _, ok := params["dkim"]; !ok {
if _, ok := params["dm"]; !ok {
return nil
}
}
}
}
}
config := DefaultDnsRecordsConfig()
if d, ok := params["d"]; ok {
config.Domain = dec(d)
}
if sel, ok := params["sel"]; ok {
config.Selector = dec(sel)
}
if spf, ok := params["spf"]; ok {
terms := []string{}
for _, t := range strings.Split(spf, ",") {
if t != "" {
terms = append(terms, dec(t))
}
}
config.SPF = SpfPart{Enabled: true, Terms: terms}
}
if dkimParam, ok := params["dkim"]; ok {
sec := sections(dkimParam)
keyType := "rsa"
if k, ok := sec["k"]; ok {
keyType = dec(k)
}
hashes := []string{}
for _, t := range strings.Split(sec["h"], ",") {
if t != "" {
hashes = append(hashes, dec(t))
}
}
config.DKIM = DkimPart{
Enabled: true,
KeyType: keyType,
Hashes: hashes,
TestMode: sec["t"] == "1",
PublicKey: dec(sec["p"]),
}
}
if dm, ok := params["dm"]; ok {
sec := sections(dm)
policy := "none"
if p, ok := sec["p"]; ok {
policy = p
}
sub := ""
if s, ok := sec["sp"]; ok {
sub = s
}
if !policyValues[policy] {
policy = "none"
}
subdomainPolicy := ""
if policyValues[sub] {
subdomainPolicy = sub
}
config.DMARC = DmarcPart{
Enabled: true,
Policy: policy,
SubdomainPolicy: subdomainPolicy,
Rua: decList(sec["rua"]),
Ruf: decList(sec["ruf"]),
DKIMAlignment: alignmentOr(sec["adkim"]),
SPFAlignment: alignmentOr(sec["aspf"]),
}
if raw, ok := sec["pct"]; ok && raw != "" {
if f, err := strconv.ParseFloat(raw, 64); err == nil {
pct := int(f)
config.DMARC.Percent = &pct
}
}
}
return &config
}
func decList(param string) []string {
out := []string{}
for _, t := range strings.Split(param, ",") {
if t != "" {
out = append(out, dec(t))
}
}
return out
}
func alignmentOr(v string) string {
if v == "s" {
return "s"
}
return "r"
}
Also available in 9 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →