DKIM / SPF / DMARC Builder & Checker — TypeScript 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 TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Email authentication (SPF / DKIM / DMARC) record parsing and lookup.
//
// Two layers, mirroring src/lib/whois.ts:
// - Pure parsers (parseSPF / parseDKIM / parseDMARC) — deterministic, never
// throw, unit-tested without network.
// - Check functions (checkSPF / checkDKIM / checkDMARC) — one DNS-over-HTTPS
// fetch to Cloudflare's public resolver from the browser, then a pure
// parse. CosmoDev runs no backend for this tool.
//
// Specs: SPF RFC 7208, DKIM RFC 6376, DMARC RFC 7489.
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type CheckStatus = 'pass' | 'warn' | 'fail';
export type SPFQualifier = '+' | '-' | '~' | '?';
export interface SPFMechanism {
qualifier: SPFQualifier;
kind: 'all' | 'include' | 'a' | 'mx' | 'ip4' | 'ip6' | 'exists' | 'ptr';
/** Domain, IP, or CIDR argument after the colon (e.g. `_spf.google.com`, `192.0.2.0/24`). */
value?: string;
}
export interface SPFRecord {
valid: boolean;
version?: string;
mechanisms: SPFMechanism[];
redirect?: string;
exp?: string;
/** Mechanisms that cost a DNS query under RFC 7208 §4.6.4 (limit 10). */
lookupCount: number;
/** Number of `v=spf1` records found (more than one is a hard error for receivers). */
recordCount: number;
warnings: string[];
}
export interface DKIMRecord {
valid: boolean;
version?: string;
keyType: string;
/** Public key, base64, whitespace-stripped. */
publicKey?: string;
/** Approximate modulus size in bits (RSA only; derived from DER byte length). */
keyBits?: number;
hashes: string[];
services: string[];
flags: string[];
warnings: string[];
}
export type DMARCPolicy = 'none' | 'quarantine' | 'reject';
export interface DMARCRecord {
valid: boolean;
policy?: DMARCPolicy;
subdomainPolicy?: DMARCPolicy;
aggregateUris: string[];
forensicUris: string[];
percent?: number;
dkimAlignment?: 'r' | 's';
spfAlignment?: 'r' | 's';
warnings: string[];
}
export interface SPFResult {
status: CheckStatus;
found: boolean;
record?: SPFRecord;
message?: string;
}
export interface DKIMResult {
status: CheckStatus;
found: boolean;
record?: DKIMRecord;
message?: string;
}
export interface DMARCResult {
status: CheckStatus;
found: boolean;
record?: DMARCRecord;
message?: string;
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
/** Strip a leading scheme, userinfo, path, query, port, and trailing dot from user input. */
export function normalizeDomain(raw: string): string {
let s = raw.trim();
s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); // scheme://
s = s.replace(/^mailto:/i, ''); // mailto:user@domain
if (s.includes('@')) s = s.slice(s.lastIndexOf('@') + 1); // keep host of user@host
s = s.split('/')[0]; // drop path
s = s.split('?')[0]; // drop query
s = s.split(':')[0]; // drop port
s = s.replace(/\.+$/, ''); // trailing dot(s)
return s.toLowerCase();
}
const DOMAIN_RE = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
/** True when the string looks like a plausible multi-label domain (example.com). */
export function isDomainLike(domain: string): boolean {
return DOMAIN_RE.test(domain) && domain.length <= 253;
}
const SELECTOR_RE = /^[a-z0-9][a-z0-9._-]*$/i;
/** True when the selector is a safe single DNS label chain (no spaces, no traversal). */
export function isValidSelector(selector: string): boolean {
const s = selector.trim();
return s.length > 0 && s.length <= 100 && SELECTOR_RE.test(s) && !s.includes('..');
}
// ---------------------------------------------------------------------------
// SPF — RFC 7208
// ---------------------------------------------------------------------------
const SPF_LOOKUP_KINDS = new Set(['include', 'a', 'mx', 'exists', 'ptr']);
/** Parse one or more (newline-joined) TXT record strings for SPF. Pure. */
export function parseSPF(txt: string): SPFRecord {
const lines = txt
.split('\n')
.map((l) => l.trim().replace(/^"(.*)"$/, '$1'))
.filter(Boolean);
const spfLines = lines.filter((l) => /^v=spf1(?:\s|$)/i.test(l));
const warnings: string[] = [];
if (spfLines.length === 0) {
return { valid: false, mechanisms: [], lookupCount: 0, recordCount: 0, warnings: ['No v=spf1 record found in the supplied text.'] };
}
if (spfLines.length > 1) {
warnings.push(`${spfLines.length} SPF records found — RFC 7208 allows exactly one. Receivers treat this as a permanent error and ignore SPF for the whole domain.`);
}
const terms = spfLines[0].split(/\s+/);
const version = terms[0];
const mechanisms: SPFMechanism[] = [];
let redirect: string | undefined;
let exp: string | undefined;
for (const term of terms.slice(1)) {
// Modifiers use '=': redirect= and exp=
const modifier = /^([a-z][a-z0-9-]*)=(.*)$/i.exec(term);
if (modifier) {
const name = modifier[1].toLowerCase();
if (name === 'redirect') redirect = modifier[2];
else if (name === 'exp') exp = modifier[2];
else warnings.push(`Unknown modifier "${term}" ignored.`);
continue;
}
const m = /^([+\-~?])?([a-z0-9]+)(?::(.*))?$/i.exec(term);
if (!m) {
warnings.push(`Unrecognized term "${term}" ignored.`);
continue;
}
const qualifier = (m[1] ?? '+') as SPFQualifier;
const kind = m[2].toLowerCase();
const value = m[3];
if (!['all', 'include', 'a', 'mx', 'ip4', 'ip6', 'exists', 'ptr'].includes(kind)) {
warnings.push(`Unknown mechanism "${term}" ignored.`);
continue;
}
if (!value && (kind === 'include' || kind === 'exists')) {
warnings.push(`Mechanism "${term}" is missing its required value.`);
continue;
}
mechanisms.push({ qualifier, kind: kind as SPFMechanism['kind'], value });
}
const lookupCount = mechanisms.filter((mech) => SPF_LOOKUP_KINDS.has(mech.kind)).length + (redirect ? 1 : 0);
const all = mechanisms.find((mech) => mech.kind === 'all');
if (!all && !redirect) {
warnings.push('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 && all.qualifier === '+') {
warnings.push('"+all" explicitly allows every host on the internet to send mail as this domain — this defeats SPF entirely.');
} else if (all && all.qualifier === '?') {
warnings.push('"?all" (Neutral) lets unmatched senders through with no protection. Prefer "~all" or "-all".');
}
if (mechanisms.some((mech) => mech.kind === 'ptr')) {
warnings.push('The "ptr" mechanism is deprecated (RFC 7208 §5.5) and should not be used.');
}
if (redirect && all) {
warnings.push('A "redirect=" modifier is ignored when an "all" mechanism is present.');
}
if (lookupCount > 10) {
warnings.push(`${lookupCount} DNS-lookup mechanisms — RFC 7208 §4.6.4 caps SPF at 10. Receivers that hit the cap return permerror and ignore the record.`);
}
return { valid: true, version, mechanisms, redirect, exp, lookupCount, recordCount: spfLines.length, warnings };
}
// ---------------------------------------------------------------------------
// DKIM — RFC 6376
// ---------------------------------------------------------------------------
/** Decode base64 to a byte string without throwing on bad input. */
function decodeBase64Lenient(b64: string): string | undefined {
// Callers pass trimmed, non-empty values (empty p= is handled before this).
const clean = b64.replace(/\s+/g, '');
const padded = clean + '='.repeat((4 - (clean.length % 4)) % 4);
try {
return atob(padded);
} catch {
return undefined;
}
}
/** Parse a `<selector>._domainkey` TXT record. Pure. */
export function parseDKIM(txt: string): DKIMRecord {
const warnings: string[] = [];
const tags = new Map<string, string>();
for (const part of txt.split(';')) {
const term = part.trim().replace(/^"(.*)"$/, '$1').trim();
if (!term) continue;
const eq = term.indexOf('=');
if (eq <= 0) {
warnings.push(`Malformed tag "${term}" ignored.`);
continue;
}
tags.set(term.slice(0, eq).trim().toLowerCase(), term.slice(eq + 1).trim());
}
const version = tags.get('v');
if (version && version.toUpperCase() !== 'DKIM1') {
warnings.push(`Unusual version tag v=${version} (expected DKIM1).`);
}
const keyType = tags.get('k') ?? 'rsa';
const p = tags.get('p');
const hashes = (tags.get('h') ?? '').split(':').map((s) => s.trim()).filter(Boolean);
const services = (tags.get('s') ?? '').split(':').map((s) => s.trim()).filter(Boolean);
const flags = (tags.get('t') ?? '').split(':').map((s) => s.trim()).filter(Boolean);
const record: DKIMRecord = { valid: true, keyType, hashes, services, flags, warnings };
if (version) record.version = version.toUpperCase();
if (p === undefined) {
record.valid = false;
warnings.push('No p= tag — this record is not a usable DKIM key.');
return record;
}
if (p === '') {
warnings.push('p= is empty — the key is revoked. Receivers will treat mail signed with this selector as unsigned.');
return record;
}
const decoded = decodeBase64Lenient(p);
if (decoded === undefined) {
warnings.push('The p= value is not valid base64 — the key could not be read.');
return record;
}
record.publicKey = p.replace(/\s+/g, '');
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.
const bits = Math.max(0, decoded.length - 24) * 8;
record.keyBits = bits;
if (bits < 1024) {
warnings.push(`Weak RSA key (~${bits} bits). Keys under 1024 bits are considered breakable; RFC 8301 discourages short keys.`);
} else if (bits < 2048) {
warnings.push(`RSA key of ~${bits} bits works today but is below the recommended 2048 bits (RFC 8301).`);
}
}
if (flags.includes('y')) {
warnings.push('t=y — the key is in test mode: receivers must treat signatures as if unsigned.');
}
if (flags.includes('s')) {
warnings.push('t=s — strict domain matching: the key cannot be used for subdomain signatures (informational).');
}
return record;
}
// ---------------------------------------------------------------------------
// DMARC — RFC 7489
// ---------------------------------------------------------------------------
const DMARC_POLICIES: DMARCPolicy[] = ['none', 'quarantine', 'reject'];
/** Parse a `_dmarc` TXT record. Pure. */
export function parseDMARC(txt: string): DMARCRecord {
const warnings: string[] = [];
const tags = new Map<string, string>();
for (const part of txt.split(';')) {
const term = part.trim().replace(/^"(.*)"$/, '$1').trim();
if (!term) continue;
const eq = term.indexOf('=');
if (eq <= 0) {
warnings.push(`Malformed tag "${term}" ignored.`);
continue;
}
tags.set(term.slice(0, eq).trim().toLowerCase(), term.slice(eq + 1).trim());
}
const record: DMARCRecord = { valid: true, aggregateUris: [], forensicUris: [], warnings };
const version = tags.get('v');
if (!version) {
record.valid = false;
warnings.push('No v= tag — this is not a DMARC record.');
return record;
}
if (version.toUpperCase() !== 'DMARC1') {
record.valid = false;
warnings.push(`Unknown version v=${version} (expected DMARC1).`);
return record;
}
const p = tags.get('p')?.toLowerCase();
if (!p) {
record.valid = false;
warnings.push('No p= policy tag — DMARC requires it.');
return record;
}
if (!DMARC_POLICIES.includes(p as DMARCPolicy)) {
record.valid = false;
warnings.push(`Invalid policy p=${p} (expected none, quarantine, or reject).`);
return record;
}
record.policy = p as DMARCPolicy;
const sp = tags.get('sp')?.toLowerCase();
if (sp) {
if (DMARC_POLICIES.includes(sp as DMARCPolicy)) record.subdomainPolicy = sp as DMARCPolicy;
else warnings.push(`Invalid sp=${sp} ignored (expected none, quarantine, or reject).`);
}
const rua = tags.get('rua');
if (rua) record.aggregateUris = rua.split(',').map((u) => u.trim()).filter(Boolean);
const ruf = tags.get('ruf');
if (ruf) record.forensicUris = ruf.split(',').map((u) => u.trim()).filter(Boolean);
const pct = tags.get('pct');
if (pct !== undefined) {
const n = Number(pct);
if (!Number.isInteger(n) || n < 0 || n > 100) {
warnings.push(`Invalid pct=${pct} ignored (must be 0-100).`);
} else {
record.percent = n;
}
}
const adkim = tags.get('adkim');
if (adkim) {
if (adkim === 'r' || adkim === 's') record.dkimAlignment = adkim;
else warnings.push(`Invalid adkim=${adkim} ignored (expected r or s).`);
}
const aspf = tags.get('aspf');
if (aspf) {
if (aspf === 'r' || aspf === 's') record.spfAlignment = aspf;
else warnings.push(`Invalid aspf=${aspf} ignored (expected r or s).`);
}
// Policy guidance
if (record.policy === 'none') {
warnings.push('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 (record.aggregateUris.length === 0) {
warnings.push('No rua= address — without aggregate reports you cannot see who is failing authentication. Add rua=mailto:reports@example.com.');
} else if (record.forensicUris.length > 0) {
warnings.push('ruf= (forensic reports) is supported by few receivers and may leak message content to the report address (informational).');
}
if (record.percent !== undefined && record.percent < 100 && record.policy !== 'none') {
warnings.push(`pct=${record.percent} applies the policy to only ${record.percent}% of mail — the other ${100 - record.percent}% is unaffected.`);
}
return record;
}
// ---------------------------------------------------------------------------
// DNS-over-HTTPS lookup
// ---------------------------------------------------------------------------
const DOH_ENDPOINT = 'https://cloudflare-dns.com/dns-query';
interface DohAnswer {
name: string;
type: number;
TTL: number;
data: string;
}
interface DohResponse {
Status?: number;
Answer?: DohAnswer[];
}
/** Query TXT records for a name via Cloudflare's DoH JSON API. Returns unquoted strings. */
async function queryTxt(name: string): Promise<string[]> {
const res = await fetch(`${DOH_ENDPOINT}?name=${encodeURIComponent(name)}&type=TXT`, {
headers: { Accept: 'application/dns-json' },
});
if (!res.ok) throw new Error(`DNS resolver responded with HTTP ${res.status}.`);
const json = (await res.json()) as DohResponse;
if (json.Status === 3) return []; // NXDOMAIN — no such domain
if (json.Status !== 0) throw new Error(`DNS query failed with status ${json.Status}.`);
return (json.Answer ?? [])
.filter((a) => a.type === 16)
.map((a) => a.data.replace(/^"(.*)"$/, '$1').replace(/" "/g, '')); // multi-chunk TXT: "part1" "part2"
}
function warnIf(f: boolean, message: string): string | undefined {
return f ? message : undefined;
}
/** Look up and evaluate a domain's SPF record. */
export async function checkSPF(domain: string): Promise<SPFResult> {
const host = normalizeDomain(domain);
if (!isDomainLike(host)) return { status: 'fail', found: false, message: 'Enter a valid domain, e.g. example.com.' };
try {
const txts = await queryTxt(host);
const joined = txts.join('\n');
const record = parseSPF(joined);
if (!record.valid) return { status: 'fail', found: false, message: `No SPF record found for ${host}. Receivers cannot verify which servers may send mail for it.` };
const status: CheckStatus = record.warnings.length > 0 ? 'warn' : 'pass';
return { status, found: true, record, message: warnIf(status === 'pass', 'SPF record found and looks healthy.') };
} catch (err) {
return { status: 'fail', found: false, message: err instanceof Error ? err.message : 'DNS lookup failed.' };
}
}
/** Look up and evaluate a domain's DKIM public key for one selector. */
export async function checkDKIM(domain: string, selector: string): Promise<DKIMResult> {
const host = normalizeDomain(domain);
if (!isDomainLike(host)) return { status: 'fail', found: false, message: 'Enter a valid domain, e.g. example.com.' };
const sel = selector.trim().toLowerCase();
if (!isValidSelector(sel)) return { status: 'fail', found: false, message: 'Enter a valid selector (letters, digits, dots, hyphens, underscores).' };
try {
const txts = await queryTxt(`${sel}._domainkey.${host}`);
const record = parseDKIM(txts.join('\n'));
if (!record.valid) {
return { status: 'fail', found: false, message: `No DKIM record found at ${sel}._domainkey.${host}. Try another selector — only one is checked per lookup.` };
}
const revoked = record.warnings.some((w) => w.includes('revoked'));
const status: CheckStatus = revoked ? 'fail' : record.warnings.length > 0 ? 'warn' : 'pass';
return { status, found: true, record };
} catch (err) {
return { status: 'fail', found: false, message: err instanceof Error ? err.message : 'DNS lookup failed.' };
}
}
/** Look up and evaluate a domain's DMARC policy. */
export async function checkDMARC(domain: string): Promise<DMARCResult> {
const host = normalizeDomain(domain);
if (!isDomainLike(host)) return { status: 'fail', found: false, message: 'Enter a valid domain, e.g. example.com.' };
try {
const txts = await queryTxt(`_dmarc.${host}`);
const record = parseDMARC(txts.join('\n'));
if (!record.valid) {
return { status: 'fail', found: false, message: `No DMARC record found at _dmarc.${host}. Receivers have no policy to apply when SPF or DKIM fails.` };
}
const status: CheckStatus = record.warnings.length > 0 ? 'warn' : 'pass';
return { status, found: true, record };
} catch (err) {
return { status: 'fail', found: false, message: err instanceof Error ? err.message : 'DNS lookup failed.' };
}
}
// ---------------------------------------------------------------------------
// Record builder — composes the three records as ready-to-paste TXT values
// ---------------------------------------------------------------------------
export interface SpfPart {
enabled: boolean;
/** Raw terms after "v=spf1" — mechanisms and modifiers, e.g. ['include:_spf.google.com', '~all']. */
terms: string[];
}
export interface DkimPart {
enabled: boolean;
/** k= tag — 'rsa' or 'ed25519'. */
keyType: string;
/** p= tag — the base64 public key from the mail provider. */
publicKey: string;
/** h= tag values, e.g. ['sha256']. */
hashes: string[];
/** t=y test mode. */
testMode: boolean;
}
export type DmarcPolicy = '' | 'none' | 'quarantine' | 'reject';
export interface DmarcPart {
enabled: boolean;
/** p= tag. '' means not chosen yet. */
policy: DmarcPolicy;
/** sp= tag. '' omits it (subdomains inherit p=). */
subdomainPolicy: DmarcPolicy;
/** rua= aggregate report URIs. */
rua: string[];
/** ruf= forensic report URIs. */
ruf: string[];
/** pct= tag; null omits it. */
percent: number | null;
dkimAlignment: 'r' | 's';
spfAlignment: 'r' | 's';
}
export interface DnsRecordsConfig {
domain: string;
selector: string;
dkim: DkimPart;
spf: SpfPart;
dmarc: DmarcPart;
}
export type DnsIssueCode =
| 'selector-missing'
| 'spf-multiple-records'
| 'spf-all-missing'
| 'dmarc-policy-missing'
| 'domain-not-fqdn';
export interface DnsIssue {
code: DnsIssueCode;
severity: 'warn' | 'info';
value?: string;
}
/** Fresh builder state: all three records on, Google-Workspace-shaped defaults. */
export function defaultDnsRecordsConfig(): DnsRecordsConfig {
return {
domain: 'example.com',
selector: 'google',
spf: { enabled: true, terms: ['include:_spf.google.com', '~all'] },
dkim: { enabled: true, keyType: 'rsa', publicKey: '', hashes: ['sha256'], testMode: false },
dmarc: {
enabled: true,
policy: 'none',
subdomainPolicy: '',
rua: ['mailto:dmarc@example.com'],
ruf: [],
percent: null,
dkimAlignment: 'r',
spfAlignment: 'r',
},
};
}
const cloneConfig = (c: DnsRecordsConfig): DnsRecordsConfig =>
JSON.parse(JSON.stringify(c)) as DnsRecordsConfig;
/** SPF TXT value ("v=spf1 include:… ~all"). */
export function spfValue(part: SpfPart): string {
return `v=spf1 ${part.terms.map((t) => t.trim()).filter(Boolean).join(' ')}`.trimEnd();
}
/** DKIM TXT value ("v=DKIM1; k=rsa; p=…"). */
export function dkimValue(part: DkimPart): string {
const tags = ['v=DKIM1', `k=${part.keyType.trim() || 'rsa'}`];
const hashes = part.hashes.map((h) => h.trim()).filter(Boolean);
if (hashes.length > 0) tags.push(`h=${hashes.join(':')}`);
if (part.testMode) tags.push('t=y');
tags.push(`p=${part.publicKey.replace(/\s+/g, '')}`);
return tags.join('; ');
}
/** DMARC TXT value ("v=DMARC1; p=…; rua=…"). */
export function dmarcValue(part: DmarcPart): string {
const tags = ['v=DMARC1', `p=${part.policy || 'none'}`];
if (part.subdomainPolicy) tags.push(`sp=${part.subdomainPolicy}`);
if (part.rua.length > 0) tags.push(`rua=${part.rua.join(',')}`);
if (part.ruf.length > 0) tags.push(`ruf=${part.ruf.join(',')}`);
if (part.percent != null) tags.push(`pct=${part.percent}`);
tags.push(`adkim=${part.dkimAlignment}`, `aspf=${part.spfAlignment}`);
return tags.join('; ');
}
/**
* 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 throws.
*/
export function generateDnsRecords(config: DnsRecordsConfig): string {
const domain = normalizeDomain(config.domain) || 'example.com';
const selector = config.selector.trim().toLowerCase() || 'default';
const out: string[] = [
`; Email authentication (SPF / DKIM / DMARC) records for ${domain}`,
'; Publish each block as a TXT record at your DNS provider.',
'',
];
if (config.spf.enabled) {
out.push(`; SPF — TXT at ${domain} (the zone apex; often written "@")`);
out.push(`${domain}. IN TXT "${spfValue(config.spf)}"`, '');
}
if (config.dkim.enabled) {
out.push(`; DKIM — TXT at ${selector}._domainkey.${domain}`);
out.push(`${selector}._domainkey.${domain}. IN TXT "${dkimValue(config.dkim)}"`, '');
}
if (config.dmarc.enabled) {
out.push(`; DMARC — TXT at _dmarc.${domain}`);
out.push(`_dmarc.${domain}. IN TXT "${dmarcValue(config.dmarc)}"`, '');
}
return `${out.join('\n').trimEnd()}\n`;
}
/** Builder validation — the five classic publishing mistakes. */
export function validateDnsRecords(config: DnsRecordsConfig): DnsIssue[] {
const issues: DnsIssue[] = [];
const domain = config.domain.trim();
if (domain !== '' && !isDomainLike(normalizeDomain(domain))) {
issues.push({ code: 'domain-not-fqdn', severity: 'warn', value: domain });
}
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.
const spfLines = spfValue(config.spf).split(/\s+/).filter((t) => /^v=spf1$/i.test(t)).length;
if (spfLines > 1) issues.push({ code: 'spf-multiple-records', severity: 'warn', value: String(spfLines) });
const rec = parseSPF(spfValue(config.spf));
if (!rec.mechanisms.some((m) => m.kind === 'all') && !rec.redirect) {
issues.push({ code: 'spf-all-missing', severity: 'warn' });
}
}
if (config.dkim.enabled && config.selector.trim() === '') {
issues.push({ code: 'selector-missing', severity: 'warn' });
}
if (config.dmarc.enabled && config.dmarc.policy === '') {
issues.push({ code: 'dmarc-policy-missing', severity: 'warn' });
}
return issues;
}
export interface ParsedDnsRecords {
config: DnsRecordsConfig;
found: { spf: boolean; dkim: boolean; dmarc: boolean };
}
/** Strip a zone-file prefix (`name IN TXT "…"`) down to the record value itself. */
function txtPayload(line: string): string {
const q = line.indexOf('"');
if (q === -1) return line.trim();
const end = line.lastIndexOf('"');
return (end > q ? line.slice(q + 1, end) : line.slice(q + 1)).trim();
}
/** Record host of a zone line (`_dmarc.example.com.` → `_dmarc.example.com`); '' when the line is a bare value. */
function recordName(line: string): string {
const q = line.indexOf('"');
const head = (q === -1 ? line : line.slice(0, q)).trim();
if (!/\s/.test(head)) return '';
return head.split(/\s+/)[0].replace(/\.+$/, '');
}
/**
* Parse pasted TXT record values (bare values or full zone lines) into
* builder state. Parts not present keep their `prev` values. Never throws.
*/
export function parseDnsRecords(text: string, prev: DnsRecordsConfig): ParsedDnsRecords {
const config = cloneConfig(prev);
const found = { spf: false, dkim: false, dmarc: false };
const spfLines: string[] = [];
let spfName = '';
let dkimPayload = '';
let dkimName = '';
let dmarcPayload = '';
let dmarcName = '';
for (const raw of (text ?? '').split('\n')) {
const line = raw.trim();
if (!line) continue;
const payload = txtPayload(line);
const name = recordName(line);
if (/v=spf1(\s|$)/i.test(payload)) {
spfLines.push(payload);
if (!spfName) spfName = name;
} else if (/v=DKIM/i.test(payload) || name.includes('._domainkey')) {
dkimPayload = payload;
dkimName = name;
} else if (/v=DMARC/i.test(payload) || name.startsWith('_dmarc.')) {
dmarcPayload = payload;
dmarcName = name;
}
}
// Sniff the record host for domain and selector: an explicit name wins.
const dmarcDomain = dmarcName.startsWith('_dmarc.') ? dmarcName.slice('_dmarc.'.length) : '';
const dkimAt = dkimName.indexOf('._domainkey.');
const sniffDomain = dmarcDomain || (dkimAt > 0 ? dkimName.slice(dkimAt + '._domainkey.'.length) : '') || spfName;
if (sniffDomain !== '' && isDomainLike(sniffDomain)) config.domain = sniffDomain;
if (dkimAt > 0) config.selector = dkimName.slice(0, dkimAt);
if (spfLines.length > 0) {
// First record's terms are unpacked; any further whole records stay as
// terms so validateDnsRecords can flag them.
const terms: string[] = [];
spfLines.forEach((l, i) => {
if (i === 0) {
terms.push(...l.replace(/^\s*v=spf1\s*/i, '').split(/\s+/).map((t) => t.trim()).filter(Boolean));
} else {
terms.push(l.trim());
}
});
config.spf = { enabled: true, terms };
found.spf = true;
}
if (dkimPayload !== '') {
const rec = parseDKIM(dkimPayload);
if (rec.valid) {
config.dkim = {
enabled: true,
keyType: rec.keyType,
publicKey: rec.publicKey ?? '',
hashes: rec.hashes,
testMode: rec.flags.includes('y'),
};
found.dkim = true;
}
}
if (dmarcPayload !== '') {
const rec = parseDMARC(dmarcPayload);
if (rec.valid) {
config.dmarc = {
enabled: true,
// parseDMARC only reports valid=true once p= is set — policy is defined.
policy: rec.policy!,
subdomainPolicy: rec.subdomainPolicy ?? '',
rua: rec.aggregateUris,
ruf: rec.forensicUris,
percent: rec.percent ?? null,
dkimAlignment: rec.dkimAlignment ?? 'r',
spfAlignment: rec.spfAlignment ?? 'r',
};
found.dmarc = true;
}
}
return { config, found };
}
// URL codecs for shareable state. Each component is encodeURIComponent'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.
const enc = (s: string) => encodeURIComponent(s);
const dec = (s: string): string => {
try {
return decodeURIComponent(s);
} catch {
return s; // malformed escape — keep verbatim rather than throw
}
};
/** Split "k=rsa|h=sha256|…" sections into a tag → value map. */
function sections(param: string): Map<string, string> {
return new Map(
param.split('|').map((s) => {
const eq = s.indexOf('=');
return [s.slice(0, eq), s.slice(eq + 1)];
}),
);
}
const POLICY_VALUES: readonly string[] = ['none', 'quarantine', 'reject'];
export function toQuery(config: DnsRecordsConfig): string {
const p = new URLSearchParams();
if (config.domain.trim() !== '') p.set('d', enc(config.domain.trim()));
if (config.selector.trim() !== '') p.set('sel', enc(config.selector.trim()));
if (config.spf.enabled) {
p.set('spf', config.spf.terms.map((t) => enc(t.trim())).join(','));
}
if (config.dkim.enabled) {
p.set(
'dkim',
[
`k=${enc(config.dkim.keyType)}`,
`h=${config.dkim.hashes.map(enc).join(',')}`,
config.dkim.testMode ? 't=1' : 't=0',
`p=${enc(config.dkim.publicKey)}`,
].join('|'),
);
}
if (config.dmarc.enabled) {
p.set(
'dm',
[
`p=${config.dmarc.policy}`,
`sp=${config.dmarc.subdomainPolicy}`,
`rua=${config.dmarc.rua.map(enc).join(',')}`,
`ruf=${config.dmarc.ruf.map(enc).join(',')}`,
config.dmarc.percent != null ? `pct=${config.dmarc.percent}` : 'pct=',
`adkim=${config.dmarc.dkimAlignment}`,
`aspf=${config.dmarc.spfAlignment}`,
].join('|'),
);
}
return p.toString();
}
export function fromQuery(params: URLSearchParams): DnsRecordsConfig | null {
if (!params.has('d') && !params.has('sel') && !params.has('spf') && !params.has('dkim') && !params.has('dm')) {
return null;
}
const config = defaultDnsRecordsConfig();
const d = params.get('d');
if (d !== null) config.domain = dec(d);
const sel = params.get('sel');
if (sel !== null) config.selector = dec(sel);
const spf = params.get('spf');
if (spf !== null) {
config.spf = { enabled: true, terms: spf.split(',').filter((t) => t !== '').map(dec) };
}
const dkim = params.get('dkim');
if (dkim !== null) {
const sec = sections(dkim);
config.dkim = {
enabled: true,
keyType: dec(sec.get('k') ?? 'rsa'),
hashes: (sec.get('h') ?? '').split(',').filter((t) => t !== '').map(dec),
testMode: sec.get('t') === '1',
publicKey: dec(sec.get('p') ?? ''),
};
}
const dm = params.get('dm');
if (dm !== null) {
const sec = sections(dm);
const policy = sec.get('p') ?? 'none';
const sub = sec.get('sp') ?? '';
const pct = Number(sec.get('pct'));
config.dmarc = {
enabled: true,
policy: POLICY_VALUES.includes(policy) ? (policy as DmarcPolicy) : 'none',
subdomainPolicy: POLICY_VALUES.includes(sub) ? (sub as DmarcPolicy) : '',
rua: (sec.get('rua') ?? '').split(',').filter((t) => t !== '').map(dec),
ruf: (sec.get('ruf') ?? '').split(',').filter((t) => t !== '').map(dec),
percent: sec.get('pct') !== '' && Number.isFinite(pct) ? pct : null,
dkimAlignment: sec.get('adkim') === 's' ? 's' : 'r',
spfAlignment: sec.get('aspf') === 's' ? 's' : 'r',
};
}
return config;
}
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 →