Documentation
¶
Overview ¶
Package mailrecords builds, plans, applies and checks the DNS records a domain needs for email. It is engine agnostic: the same records serve a self-hosted mail server or any external provider, and it costs the server nothing beyond the DNS provider calls.
Index ¶
- Constants
- Variables
- func Build(spec Spec) ([]Record, []Note, error)
- func ClassifyNameservers(ns []string) string
- func FindZoneCut(ctx context.Context, r NSResolver, domain, apex string) (string, []string, error)
- func HostFromPTR(names []string) string
- func NormalizeReplace(in []string) ([]string, error)
- func PTRHint(host string) string
- func PresetIDs() []string
- func RegistrableApex(domain string) (string, error)
- func ShadowNames(desired []Record, domain string) map[string]bool
- func ValidHost(h string) error
- func Wants(desired []Record, domain string) []dnsstatus.Want
- func ZoneSets(sets []dnszones.RecordSet, zone string) []dnsstatus.ZoneSet
- type AppliedRecord
- type Change
- type DKIMCNAME
- type Detection
- type DomainConnect
- type Expect
- type Item
- type MXEntry
- type NSResolver
- type Note
- type PlanOptions
- type PlanResult
- type Preset
- type Record
- type RecordIDer
- type Report
- type Resolver
- type Spec
Constants ¶
const ( KindMX = "MX" KindHost = "HOST" KindSPF = "SPF" KindDKIM = "DKIM" KindDMARC = "DMARC" KindMTASTS = "MTASTS" KindTLSRPT = "TLSRPT" KindAutoconfig = "AUTOCONFIG" )
Record kinds, also the values ReplaceExisting accepts.
const ( SPFServer = "server" SPFInclude = "include" )
SPF modes.
const ( NoteInfo = "info" NoteWarn = "warn" )
Note levels.
const ( StatusPass = "pass" StatusWarn = "warn" StatusFail = "fail" StatusNotTested = "not_tested" )
Check statuses.
const ( HostCloudflare = "cloudflare" HostRoute53 = "route53" HostDigitalOcean = "digitalocean" HostGoogleCloud = "googlecloud" HostAzure = "azure" HostHetzner = "hetzner" HostNamecheap = "namecheap" HostGoDaddy = "godaddy" HostManual = "manual" )
DNS hosts the nameserver classifier knows. Anything else is HostManual.
const ( ActionAdd = "add" ActionUnchanged = "unchanged" ActionReplace = "replace" ActionRefused = "refused" )
Plan actions.
const ManagedComment = "managed by levelrail mail"
ManagedComment tags every record mail DNS creates where the provider keeps comments.
Variables ¶
var ( ErrStalePlan = errors.New("the zone or the request changed since the plan was made; run plan again") ErrBlocked = errors.New("the plan is blocked; fix the refusals or list the record types in replace_existing") )
Errors Apply returns so callers can map them to a status.
var Presets = map[string]Preset{ "google": { ID: "google", Name: "Google Workspace", Source: "Google Workspace Admin Help, MX record values", MX: []MXEntry{ {1, "aspmx.l.google.com"}, {5, "alt1.aspmx.l.google.com"}, {5, "alt2.aspmx.l.google.com"}, {10, "alt3.aspmx.l.google.com"}, {10, "alt4.aspmx.l.google.com"}, }, SPFInclude: "_spf.google.com", Note: "Generate the DKIM key in the Google admin console (selector google by default), then re-run with the key.", }, "fastmail": { ID: "fastmail", Name: "Fastmail", Source: "Fastmail help, DNS settings for your domain", MX: []MXEntry{{10, "in1-smtp.messagingengine.com"}, {20, "in2-smtp.messagingengine.com"}}, SPFInclude: "spf.messagingengine.com", DKIMCNAMEs: []DKIMCNAME{ {"fm1", "fm1.%s.dkim.fmhosted.com"}, {"fm2", "fm2.%s.dkim.fmhosted.com"}, {"fm3", "fm3.%s.dkim.fmhosted.com"}, }, Note: "Confirm the three DKIM CNAMEs against the values Fastmail shows for your domain before relying on them.", }, "proton": { ID: "proton", Name: "Proton Mail", Source: "Proton Mail support, custom domain setup", MX: []MXEntry{{10, "mail.protonmail.ch"}, {20, "mailsec.protonmail.ch"}}, SPFInclude: "_spf.protonmail.ch", Note: "Proton also needs a domain verification TXT and three DKIM CNAMEs that are unique to your account; copy them from the Proton dashboard.", }, "zoho": { ID: "zoho", Name: "Zoho Mail (US data center)", Source: "Zoho Mail help, MX records for the .com data center", MX: []MXEntry{{10, "mx.zoho.com"}, {20, "mx2.zoho.com"}, {50, "mx3.zoho.com"}}, SPFInclude: "zoho.com", Note: "Accounts in the EU or India data centers use different hosts (zoho.eu, zoho.in); this preset is the .com data center only. The DKIM key is generated in Zoho.", }, "ses": { ID: "ses", Name: "Amazon SES (sending only)", Source: "AWS SES developer guide, SPF authentication", SPFInclude: "amazonses.com", SendingOnly: true, Note: "Publish the three DKIM CNAMEs and the custom MAIL FROM records shown in the SES console; they are unique to your identity.", }, "mailgun": { ID: "mailgun", Name: "Mailgun (sending only)", Source: "Mailgun documentation, domain verification", SPFInclude: "mailgun.org", SendingOnly: true, Note: "Publish the DKIM TXT and tracking CNAME shown on the Mailgun domain page; the key is unique to your domain.", }, "postmark": { ID: "postmark", Name: "Postmark (sending only)", Source: "Postmark support, SPF and the Return-Path domain", SPFInclude: "spf.mtasv.net", SendingOnly: true, Note: "Postmark prefers a Return-Path CNAME (pm-bounces) over an apex SPF include; add the DKIM TXT and Return-Path from your Postmark sender signature.", }, }
Presets is the data table, keyed by id. Resend is left out on purpose: its SPF and return-path records live on a per-account send subdomain.
Functions ¶
func Build ¶
Build returns the record sets for spec, plus notes for everything that cannot be a record. It is pure: no network, no clock.
func ClassifyNameservers ¶
ClassifyNameservers names the DNS host when every nameserver agrees; mixed or unknown sets are manual.
func FindZoneCut ¶
FindZoneCut walks from domain up to its registrable apex and returns the first name that has NS records, so a delegated subdomain reports its own host.
func HostFromPTR ¶
HostFromPTR guesses the hosting provider from the PTR names of the server IP; "" when unknown.
func NormalizeReplace ¶
NormalizeReplace validates and canonicalizes the record kinds a caller may replace.
func RegistrableApex ¶
RegistrableApex returns the public suffix plus one label (example.co.uk for a.example.co.uk).
func ShadowNames ¶
ShadowNames are the names a proxied record would hide from mail.
func ValidHost ¶
ValidHost enforces the package convention: a lower case absolute name with at least two labels and no trailing dot, scheme, port or spaces.
Types ¶
type AppliedRecord ¶
type AppliedRecord struct {
Name string `json:"name"`
Type string `json:"type"`
Action string `json:"action"`
IDs []string `json:"ids"`
}
AppliedRecord is one record set mail DNS wrote, with the provider ids of the values it added.
func AppliedRecords ¶
func AppliedRecords(ctx context.Context, p dnszones.Provider, zone dnszones.Zone, res PlanResult) []AppliedRecord
AppliedRecords lists what an applied plan added; ids stay empty when the provider has none.
type Change ¶
type Change struct {
Action string `json:"action"`
Kind string `json:"kind"`
Set dnszones.RecordSet `json:"set"`
Before *dnszones.RecordSet `json:"before,omitempty"`
Removed []string `json:"removed,omitempty"`
Reason string `json:"reason,omitempty"`
}
Change is one record set in a plan. Set is the full desired content.
type Detection ¶
type Detection struct {
Domain string `json:"domain"`
Apex string `json:"apex"`
Zone string `json:"zone"`
Nameservers []string `json:"nameservers"`
Provider string `json:"provider"`
DomainConnect DomainConnect `json:"domain_connect"`
}
Detection is where a domain's DNS lives, from public NS and TXT lookups.
type DomainConnect ¶
type DomainConnect struct {
Supported bool `json:"supported"`
Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"`
}
DomainConnect is discovery only: the API host the DNS provider publishes. Nothing calls it.
func ParseDomainConnect ¶
func ParseDomainConnect(values []string) (DomainConnect, bool)
ParseDomainConnect validates a _domainconnect TXT value: a host name with an optional path.
type Expect ¶
type Expect struct {
MailHost string `json:"mail_host,omitempty"`
DKIMSelector string `json:"dkim_selector,omitempty"`
}
Expect carries what the operator intends, so the check can compare.
type Item ¶
type Item struct {
ID string `json:"id"`
Status string `json:"status"`
Message string `json:"message"`
Value string `json:"value,omitempty"`
}
Item is one check verdict.
type NSResolver ¶
type NSResolver interface {
LookupNS(ctx context.Context, name string) ([]*net.NS, error)
LookupTXT(ctx context.Context, name string) ([]string, error)
}
NSResolver is the public resolver surface detection needs; net.Resolver satisfies it.
type Note ¶
type Note struct {
Level string `json:"level"`
Code string `json:"code"`
Message string `json:"message"`
}
Note is advice that is not a record: a pending key, a policy file to host.
type PlanOptions ¶
type PlanOptions struct {
ReplaceExisting []string `json:"replace_existing,omitempty"`
}
PlanOptions controls what a plan may overwrite. The default is add-only.
type PlanResult ¶
type PlanResult struct {
Domain string `json:"domain"`
Provider string `json:"provider,omitempty"`
Hash string `json:"hash"`
Changes []Change `json:"changes"`
Notes []Note `json:"notes,omitempty"`
Refusals []string `json:"refusals,omitempty"`
Replacing []string `json:"replacing,omitempty"`
// Shadowed lists proxied records that would hide a mail host; they also block the plan.
Shadowed []string `json:"shadowed,omitempty"`
Summary map[string]int `json:"summary"`
Blocked bool `json:"blocked"`
// ToAdd holds the status ids an apply writes; live records are never in it.
ToAdd []string `json:"to_add"`
dnsstatus.Status
}
PlanResult is the dry-run diff. Hash pins it so a stale plan cannot be applied.
func Apply ¶
func Apply(ctx context.Context, p dnszones.Provider, zone dnszones.Zone, spec Spec, opts PlanOptions, hash string) (int, PlanResult, error)
Apply recomputes the plan, refuses it when the hash differs from the one the caller confirmed, and writes the add and replace changes.
func Plan ¶
func Plan(ctx context.Context, p dnszones.Provider, zone dnszones.Zone, spec Spec, opts PlanOptions) (PlanResult, error)
Plan reads the zone and diffs the desired records against it without writing.
func (PlanResult) AuditSubject ¶
func (r PlanResult) AuditSubject(applied int) string
AuditSubject lists record names and types only, never values.
type Preset ¶
type Preset struct {
ID string `json:"id"`
Name string `json:"name"`
Source string `json:"source"`
MX []MXEntry `json:"mx,omitempty"`
SPFInclude string `json:"spf_include"`
DKIMCNAMEs []DKIMCNAME `json:"dkim_cnames,omitempty"`
SendingOnly bool `json:"sending_only,omitempty"`
Note string `json:"note"`
}
Preset holds the public DNS values of an external mail provider. Only values the provider documents for every customer are listed; per-account tokens and keys always come from the provider's own dashboard.
type RecordIDer ¶
type RecordIDer interface {
RecordIDs(ctx context.Context, zone dnszones.Zone, k dnszones.Key, values []string) ([]string, error)
}
RecordIDer is implemented by providers that expose per-record ids (Cloudflare).
type Report ¶
type Report struct {
Domain string `json:"domain"`
Items []Item `json:"items"`
// PTRHint says where reverse DNS is set, naming the host when the PTR reveals it.
PTRHint string `json:"ptr_hint"`
}
Report is the full live check of a domain.
type Resolver ¶
type Resolver interface {
LookupTXT(ctx context.Context, name string) ([]string, error)
LookupCNAME(ctx context.Context, name string) (string, error)
LookupMX(ctx context.Context, name string) ([]*net.MX, error)
LookupHost(ctx context.Context, host string) ([]string, error)
LookupAddr(ctx context.Context, addr string) ([]string, error)
LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error)
}
Resolver is the slice of net.Resolver the checks need, so tests can fake it.
type Spec ¶
type Spec struct {
Domain string `json:"domain"`
MailHost string `json:"mail_host,omitempty"`
IPv4 string `json:"ipv4,omitempty"`
IPv6 string `json:"ipv6,omitempty"`
MXPriority int `json:"mx_priority,omitempty"`
Preset string `json:"preset,omitempty"`
SPFMode string `json:"spf_mode,omitempty"`
SPFIncludes []string `json:"spf_includes,omitempty"`
DKIMSelector string `json:"dkim_selector,omitempty"`
DKIMPublicKey string `json:"dkim_public_key,omitempty"`
DMARCPolicy string `json:"dmarc_policy,omitempty"`
DMARCRua string `json:"dmarc_rua,omitempty"`
DMARCPct int `json:"dmarc_pct,omitempty"`
MTASTSMode string `json:"mta_sts_mode,omitempty"`
MTASTSMaxAge int `json:"mta_sts_max_age,omitempty"`
TLSRPT bool `json:"tls_rpt,omitempty"`
TLSRPTRua string `json:"tls_rpt_rua,omitempty"`
Autoconfig bool `json:"autoconfig,omitempty"`
TTL int `json:"ttl,omitempty"`
}
Spec is everything Build needs. Only Domain is required.