mailrecords

package
v0.9.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 6, 2026 License: Apache-2.0 Imports: 24 Imported by: 0

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

View Source
const (
	KindMX         = "MX"
	KindHost       = "HOST"
	KindSPF        = "SPF"
	KindDKIM       = "DKIM"
	KindDMARC      = "DMARC"
	KindMTASTS     = "MTASTS"
	KindTLSRPT     = "TLSRPT"
	KindAutoconfig = "AUTOCONFIG"
)

Record kinds, also the values ReplaceExisting accepts.

View Source
const (
	SPFServer  = "server"
	SPFInclude = "include"
)

SPF modes.

View Source
const (
	NoteInfo = "info"
	NoteWarn = "warn"
)

Note levels.

View Source
const (
	StatusPass      = "pass"
	StatusWarn      = "warn"
	StatusFail      = "fail"
	StatusNotTested = "not_tested"
)

Check statuses.

View Source
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.

View Source
const (
	ActionAdd       = "add"
	ActionUnchanged = "unchanged"
	ActionReplace   = "replace"
	ActionRefused   = "refused"
)

Plan actions.

View Source
const ManagedComment = "managed by levelrail mail"

ManagedComment tags every record mail DNS creates where the provider keeps comments.

Variables

View Source
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.

View Source
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

func Build(spec Spec) ([]Record, []Note, error)

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

func ClassifyNameservers(ns []string) string

ClassifyNameservers names the DNS host when every nameserver agrees; mixed or unknown sets are manual.

func FindZoneCut

func FindZoneCut(ctx context.Context, r NSResolver, domain, apex string) (string, []string, error)

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

func HostFromPTR(names []string) string

HostFromPTR guesses the hosting provider from the PTR names of the server IP; "" when unknown.

func NormalizeReplace

func NormalizeReplace(in []string) ([]string, error)

NormalizeReplace validates and canonicalizes the record kinds a caller may replace.

func PTRHint

func PTRHint(host string) string

PTRHint says where reverse DNS is set: always at the IP owner, never in the zone.

func PresetIDs

func PresetIDs() []string

PresetIDs lists the preset ids, sorted.

func RegistrableApex

func RegistrableApex(domain string) (string, error)

RegistrableApex returns the public suffix plus one label (example.co.uk for a.example.co.uk).

func ShadowNames

func ShadowNames(desired []Record, domain string) map[string]bool

ShadowNames are the names a proxied record would hide from mail.

func ValidHost

func ValidHost(h string) error

ValidHost enforces the package convention: a lower case absolute name with at least two labels and no trailing dot, scheme, port or spaces.

func Wants

func Wants(desired []Record, domain string) []dnsstatus.Want

Wants turns built records into one status entry per value, with ids stable by kind and type (mx-mx, spf-txt, host-a, mx-mx-2 for a second value).

func ZoneSets

func ZoneSets(sets []dnszones.RecordSet, zone string) []dnsstatus.ZoneSet

ZoneSets converts a provider listing for dnsstatus.FromZone.

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 DKIMCNAME

type DKIMCNAME struct {
	Selector string
	Target   string
}

DKIMCNAME is a fixed-shape DKIM delegation; %s in Target is the domain.

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.

func Detect

func Detect(ctx context.Context, r NSResolver, domain string) (Detection, error)

Detect finds the zone and classifies its DNS host. It only resolves public DNS.

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 MXEntry

type MXEntry struct {
	Priority int
	Host     string
}

MXEntry is one MX target of a hosted mail preset.

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 Record

type Record struct {
	Kind string             `json:"kind"`
	Set  dnszones.RecordSet `json:"set"`
}

Record is one record set to publish, tagged with the kind that owns it.

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.

func Check

func Check(ctx context.Context, r Resolver, domain string, exp Expect) (Report, error)

Check resolves the live DNS for domain and grades each mail record.

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL