Skip to content

DKIM / SPF / DMARC Builder & Checker — Zig 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 Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! dkim-spf-dmarc — email authentication (SPF / DKIM / DMARC) record parsing
//! and lookup.
//!
//! Language: Zig 0.14 (standard library only — std.http.Client for the
//! DNS-over-HTTPS request, std.json for the resolver's reply).
//! Ported from: src/lib/dkim-spf-dmarc.ts (the canonical TypeScript implementation).
//! display source — part of CosmoDev's polyglot tool pages.
//!
//! Two layers, matching the TS reference one-for-one:
//!   - Pure parsers (parseSPF / parseDKIM / parseDMARC) — deterministic, never
//!     fail on input shape, exercisable without a network.
//!   - Check functions (checkSPF / checkDKIM / checkDMARC) — one DoH request to
//!     Cloudflare's public resolver, then a pure parse. Every network or
//!     protocol failure comes back as a `.fail` result carrying a message,
//!     never as a thrown error; only allocation failure propagates.
//!
//! Specs: SPF RFC 7208, DKIM RFC 6376, DMARC RFC 7489.
//!
//! Ownership: every record and result owns its strings. Call `deinit` with the
//! same allocator that produced it.

const std = @import("std");
const Allocator = std.mem.Allocator;

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/// Traffic-light verdict for one check.
pub const CheckStatus = enum { pass, warn, fail };

/// SPF mechanism prefix. An absent prefix means Pass ('+'), per RFC 7208 §4.6.2.
pub const SPFQualifier = enum {
    pass, // +
    fail, // -
    soft_fail, // ~
    neutral, // ?

    pub fn symbol(self: SPFQualifier) []const u8 {
        return switch (self) {
            .pass => "+",
            .fail => "-",
            .soft_fail => "~",
            .neutral => "?",
        };
    }

    fn fromChar(c: u8) ?SPFQualifier {
        return switch (c) {
            '+' => .pass,
            '-' => .fail,
            '~' => .soft_fail,
            '?' => .neutral,
            else => null,
        };
    }
};

/// The eight mechanisms RFC 7208 defines. Anything else is ignored with a warning.
pub const SPFMechanismKind = enum {
    all,
    include,
    a,
    mx,
    ip4,
    ip6,
    exists,
    ptr,

    /// Case-insensitive lookup; null for a token that is not a known mechanism.
    fn fromString(s: []const u8) ?SPFMechanismKind {
        inline for (@typeInfo(SPFMechanismKind).@"enum".fields) |field| {
            if (std.ascii.eqlIgnoreCase(s, field.name)) return @enumFromInt(field.value);
        }
        return null;
    }

    pub fn name(self: SPFMechanismKind) []const u8 {
        return @tagName(self);
    }

    /// True when evaluating this mechanism costs a DNS query (RFC 7208 §4.6.4).
    fn costsLookup(self: SPFMechanismKind) bool {
        return switch (self) {
            .include, .a, .mx, .exists, .ptr => true,
            .all, .ip4, .ip6 => false,
        };
    }
};

pub const SPFMechanism = struct {
    qualifier: SPFQualifier,
    kind: SPFMechanismKind,
    /// Domain, IP, or CIDR argument after the colon (e.g. `_spf.google.com`,
    /// `192.0.2.0/24`). Null when the mechanism took no argument.
    value: ?[]const u8 = null,
};

pub const SPFRecord = struct {
    valid: bool,
    version: ?[]const u8 = null,
    mechanisms: []SPFMechanism = &.{},
    redirect: ?[]const u8 = null,
    exp: ?[]const u8 = null,
    /// Mechanisms that cost a DNS query under RFC 7208 §4.6.4 (limit 10).
    lookup_count: usize = 0,
    /// Number of `v=spf1` records found (more than one is a hard error for receivers).
    record_count: usize = 0,
    warnings: [][]const u8 = &.{},

    pub fn deinit(self: SPFRecord, allocator: Allocator) void {
        if (self.version) |v| allocator.free(v);
        if (self.redirect) |v| allocator.free(v);
        if (self.exp) |v| allocator.free(v);
        for (self.mechanisms) |mech| if (mech.value) |v| allocator.free(v);
        allocator.free(self.mechanisms);
        freeStrings(allocator, self.warnings);
    }
};

pub const DKIMRecord = struct {
    valid: bool,
    version: ?[]const u8 = null,
    key_type: []const u8,
    /// Public key, base64, whitespace-stripped.
    public_key: ?[]const u8 = null,
    /// Approximate modulus size in bits (RSA only; derived from DER byte length).
    key_bits: ?usize = null,
    hashes: [][]const u8 = &.{},
    services: [][]const u8 = &.{},
    flags: [][]const u8 = &.{},
    warnings: [][]const u8 = &.{},

    pub fn deinit(self: DKIMRecord, allocator: Allocator) void {
        if (self.version) |v| allocator.free(v);
        allocator.free(self.key_type);
        if (self.public_key) |v| allocator.free(v);
        freeStrings(allocator, self.hashes);
        freeStrings(allocator, self.services);
        freeStrings(allocator, self.flags);
        freeStrings(allocator, self.warnings);
    }
};

/// What a receiver should do with mail that fails alignment.
pub const DMARCPolicy = enum {
    none,
    quarantine,
    reject,

    /// Exact (already lowercased) match; null for anything else.
    fn fromString(s: []const u8) ?DMARCPolicy {
        inline for (@typeInfo(DMARCPolicy).@"enum".fields) |field| {
            if (std.mem.eql(u8, s, field.name)) return @enumFromInt(field.value);
        }
        return null;
    }

    pub fn name(self: DMARCPolicy) []const u8 {
        return @tagName(self);
    }
};

/// Identifier alignment: relaxed (organizational domain) or strict (exact).
pub const Alignment = enum { relaxed, strict };

pub const DMARCRecord = struct {
    valid: bool,
    policy: ?DMARCPolicy = null,
    subdomain_policy: ?DMARCPolicy = null,
    aggregate_uris: [][]const u8 = &.{},
    forensic_uris: [][]const u8 = &.{},
    percent: ?u8 = null,
    dkim_alignment: ?Alignment = null,
    spf_alignment: ?Alignment = null,
    warnings: [][]const u8 = &.{},

    pub fn deinit(self: DMARCRecord, allocator: Allocator) void {
        freeStrings(allocator, self.aggregate_uris);
        freeStrings(allocator, self.forensic_uris);
        freeStrings(allocator, self.warnings);
    }
};

pub const SPFResult = struct {
    status: CheckStatus,
    found: bool,
    record: ?SPFRecord = null,
    message: ?[]const u8 = null,

    pub fn deinit(self: SPFResult, allocator: Allocator) void {
        if (self.record) |rec| rec.deinit(allocator);
        if (self.message) |m| allocator.free(m);
    }
};

pub const DKIMResult = struct {
    status: CheckStatus,
    found: bool,
    record: ?DKIMRecord = null,
    message: ?[]const u8 = null,

    pub fn deinit(self: DKIMResult, allocator: Allocator) void {
        if (self.record) |rec| rec.deinit(allocator);
        if (self.message) |m| allocator.free(m);
    }
};

pub const DMARCResult = struct {
    status: CheckStatus,
    found: bool,
    record: ?DMARCRecord = null,
    message: ?[]const u8 = null,

    pub fn deinit(self: DMARCResult, allocator: Allocator) void {
        if (self.record) |rec| rec.deinit(allocator);
        if (self.message) |m| allocator.free(m);
    }
};

// ---------------------------------------------------------------------------
// Small string helpers
// ---------------------------------------------------------------------------

const ascii_space = " \t\r\n\x0b\x0c";

fn trim(s: []const u8) []const u8 {
    return std.mem.trim(u8, s, ascii_space);
}

fn freeStrings(allocator: Allocator, items: [][]const u8) void {
    for (items) |item| allocator.free(item);
    allocator.free(items);
}

/// Drop one pair of surrounding double quotes, mirroring `/^"(.*)"$/` in the
/// TS reference. A lone `"` is left alone.
fn stripQuotes(s: []const u8) []const u8 {
    if (s.len >= 2 and s[0] == '"' and s[s.len - 1] == '"') return s[1 .. s.len - 1];
    return s;
}

/// Collector for the human-readable advice each parser emits. Owns its strings
/// until `toOwnedSlice` hands them to the record.
const Warnings = struct {
    list: std.ArrayList([]const u8),

    fn init(allocator: Allocator) Warnings {
        return .{ .list = std.ArrayList([]const u8).init(allocator) };
    }

    fn add(self: *Warnings, text: []const u8) !void {
        try self.list.append(try self.list.allocator.dupe(u8, text));
    }

    fn addFmt(self: *Warnings, comptime fmt: []const u8, args: anytype) !void {
        const text = try std.fmt.allocPrint(self.list.allocator, fmt, args);
        errdefer self.list.allocator.free(text);
        try self.list.append(text);
    }

    fn contains(self: Warnings, needle: []const u8) bool {
        for (self.list.items) |w| {
            if (std.mem.indexOf(u8, w, needle) != null) return true;
        }
        return false;
    }

    fn toOwnedSlice(self: *Warnings) ![][]const u8 {
        return self.list.toOwnedSlice();
    }

    fn deinit(self: *Warnings) void {
        for (self.list.items) |w| self.list.allocator.free(w);
        self.list.deinit();
    }
};

/// Split on `sep`, trim each part, drop the empties, dupe the survivors.
/// Mirrors `.split(sep).map(trim).filter(Boolean)`.
fn splitTrimmed(allocator: Allocator, raw: []const u8, sep: u8) ![][]const u8 {
    var out = std.ArrayList([]const u8).init(allocator);
    errdefer {
        for (out.items) |item| allocator.free(item);
        out.deinit();
    }
    var it = std.mem.splitScalar(u8, raw, sep);
    while (it.next()) |part| {
        const value = trim(part);
        if (value.len == 0) continue;
        try out.append(try allocator.dupe(u8, value));
    }
    return out.toOwnedSlice();
}

/// True when `list` holds `needle` exactly.
fn containsString(list: []const []const u8, needle: []const u8) bool {
    for (list) |item| {
        if (std.mem.eql(u8, item, needle)) return true;
    }
    return false;
}

// ---------------------------------------------------------------------------
// Shared helpers — domain and selector hygiene
// ---------------------------------------------------------------------------

/// Strip a leading scheme, userinfo, path, query, port, and trailing dot from
/// user input. Returns an owned, lowercased host.
pub fn normalizeDomain(allocator: Allocator, raw: []const u8) ![]u8 {
    var s = trim(raw);

    // scheme:// — `[a-z][a-z0-9+.-]*` followed by "://"
    if (std.mem.indexOf(u8, s, "://")) |idx| {
        if (idx > 0 and std.ascii.isAlphabetic(s[0])) {
            var schemeish = true;
            for (s[1..idx]) |c| {
                if (!std.ascii.isAlphanumeric(c) and c != '+' and c != '.' and c != '-') {
                    schemeish = false;
                    break;
                }
            }
            if (schemeish) s = s[idx + 3 ..];
        }
    }

    // mailto:user@domain
    if (s.len >= 7 and std.ascii.eqlIgnoreCase(s[0..7], "mailto:")) s = s[7..];

    // keep the host of user@host
    if (std.mem.lastIndexOfScalar(u8, s, '@')) |at| s = s[at + 1 ..];

    // drop path, query, then port
    if (std.mem.indexOfScalar(u8, s, '/')) |i| s = s[0..i];
    if (std.mem.indexOfScalar(u8, s, '?')) |i| s = s[0..i];
    if (std.mem.indexOfScalar(u8, s, ':')) |i| s = s[0..i];

    // trailing root dot(s)
    s = std.mem.trimRight(u8, s, ".");

    const out = try allocator.alloc(u8, s.len);
    for (s, 0..) |c, i| out[i] = std.ascii.toLower(c);
    return out;
}

/// True when the string looks like a plausible multi-label domain
/// (`example.com`). Equivalent to `/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/`
/// against an already-lowercased host, capped at the 253-byte DNS limit.
pub fn isDomainLike(domain: []const u8) bool {
    if (domain.len == 0 or domain.len > 253) return false;

    var labels = std.mem.splitScalar(u8, domain, '.');
    var count: usize = 0;
    var last: []const u8 = &.{};

    while (labels.next()) |label| {
        count += 1;
        last = label;
        if (label.len == 0) return false;
        if (!std.ascii.isAlphanumeric(label[0])) return false;
        if (!std.ascii.isAlphanumeric(label[label.len - 1])) return false;
        for (label[1..]) |c| {
            if (!std.ascii.isAlphanumeric(c) and c != '-') return false;
        }
    }

    // At least one label plus a TLD of two or more letters.
    if (count < 2 or last.len < 2) return false;
    for (last) |c| {
        if (!std.ascii.isLower(c)) return false;
    }
    return true;
}

/// True when the selector is a safe single DNS label chain (no spaces, no
/// traversal). Equivalent to `/^[a-z0-9][a-z0-9._-]*$/i` plus the `..` guard.
pub fn isValidSelector(selector: []const u8) bool {
    const s = trim(selector);
    if (s.len == 0 or s.len > 100) return false;
    if (!std.ascii.isAlphanumeric(s[0])) return false;
    for (s[1..]) |c| {
        if (!std.ascii.isAlphanumeric(c) and c != '.' and c != '_' and c != '-') return false;
    }
    return std.mem.indexOf(u8, s, "..") == null;
}

// ---------------------------------------------------------------------------
// SPF — RFC 7208
// ---------------------------------------------------------------------------

/// True for a line that opens with the `v=spf1` version token.
fn isSpfLine(line: []const u8) bool {
    const tag = "v=spf1";
    if (line.len < tag.len) return false;
    if (!std.ascii.eqlIgnoreCase(line[0..tag.len], tag)) return false;
    return line.len == tag.len or std.ascii.isWhitespace(line[tag.len]);
}

/// Match `^([a-z][a-z0-9-]*)=(.*)$` — a modifier such as `redirect=` or `exp=`.
/// Returns the name/value split, or null when the term is not a modifier.
fn matchModifier(term: []const u8) ?struct { name: []const u8, value: []const u8 } {
    const eq = std.mem.indexOfScalar(u8, term, '=') orelse return null;
    if (eq == 0) return null;
    if (!std.ascii.isAlphabetic(term[0])) return null;
    for (term[1..eq]) |c| {
        if (!std.ascii.isAlphanumeric(c) and c != '-') return null;
    }
    return .{ .name = term[0..eq], .value = term[eq + 1 ..] };
}

/// Parse one or more (newline-joined) TXT record strings for SPF. Pure.
pub fn parseSPF(allocator: Allocator, txt: []const u8) !SPFRecord {
    var warnings = Warnings.init(allocator);
    errdefer warnings.deinit();

    // Collect the `v=spf1` lines: trim, unquote, drop the blanks.
    var spf_lines = std.ArrayList([]const u8).init(allocator);
    defer spf_lines.deinit(); // slices point into `txt`; nothing to free
    var lines = std.mem.splitScalar(u8, txt, '\n');
    while (lines.next()) |raw_line| {
        const line = stripQuotes(trim(raw_line));
        if (line.len == 0) continue;
        if (isSpfLine(line)) try spf_lines.append(line);
    }

    if (spf_lines.items.len == 0) {
        try warnings.add("No v=spf1 record found in the supplied text.");
        return .{ .valid = false, .warnings = try warnings.toOwnedSlice() };
    }
    if (spf_lines.items.len > 1) {
        try warnings.addFmt(
            "{d} SPF records found — RFC 7208 allows exactly one. Receivers treat this as a permanent error and ignore SPF for the whole domain.",
            .{spf_lines.items.len},
        );
    }

    var mechanisms = std.ArrayList(SPFMechanism).init(allocator);
    errdefer {
        for (mechanisms.items) |mech| if (mech.value) |v| allocator.free(v);
        mechanisms.deinit();
    }

    var version: ?[]const u8 = null;
    errdefer if (version) |v| allocator.free(v);
    var redirect: ?[]const u8 = null;
    errdefer if (redirect) |v| allocator.free(v);
    var exp: ?[]const u8 = null;
    errdefer if (exp) |v| allocator.free(v);

    var terms = std.mem.tokenizeAny(u8, spf_lines.items[0], ascii_space);
    version = try allocator.dupe(u8, terms.next() orelse "v=spf1"); // the version token

    while (terms.next()) |term| {
        // Modifiers use '=': redirect= and exp=. Checked first, as the TS does.
        if (matchModifier(term)) |modifier| {
            if (std.ascii.eqlIgnoreCase(modifier.name, "redirect")) {
                if (redirect) |old| allocator.free(old);
                redirect = try allocator.dupe(u8, modifier.value);
            } else if (std.ascii.eqlIgnoreCase(modifier.name, "exp")) {
                if (exp) |old| allocator.free(old);
                exp = try allocator.dupe(u8, modifier.value);
            } else {
                try warnings.addFmt("Unknown modifier \"{s}\" ignored.", .{term});
            }
            continue;
        }

        // Mechanisms: `^([+-~?])?([a-z0-9]+)(?::(.*))?$`
        var rest = term;
        var qualifier = SPFQualifier.pass; // an absent prefix means '+'
        if (rest.len > 0) {
            if (SPFQualifier.fromChar(rest[0])) |q| {
                qualifier = q;
                rest = rest[1..];
            }
        }
        const colon = std.mem.indexOfScalar(u8, rest, ':');
        const token = if (colon) |i| rest[0..i] else rest;
        const value: ?[]const u8 = if (colon) |i| rest[i + 1 ..] else null;

        var alphanumeric = token.len > 0;
        for (token) |c| {
            if (!std.ascii.isAlphanumeric(c)) alphanumeric = false;
        }
        if (!alphanumeric) {
            try warnings.addFmt("Unrecognized term \"{s}\" ignored.", .{term});
            continue;
        }

        const kind = SPFMechanismKind.fromString(token) orelse {
            try warnings.addFmt("Unknown mechanism \"{s}\" ignored.", .{term});
            continue;
        };
        const missing_value = value == null or value.?.len == 0;
        if (missing_value and (kind == .include or kind == .exists)) {
            try warnings.addFmt("Mechanism \"{s}\" is missing its required value.", .{term});
            continue;
        }

        try mechanisms.append(.{
            .qualifier = qualifier,
            .kind = kind,
            .value = if (value) |v| try allocator.dupe(u8, v) else null,
        });
    }

    var lookup_count: usize = if (redirect != null) 1 else 0;
    var all: ?SPFMechanism = null;
    var has_ptr = false;
    for (mechanisms.items) |mech| {
        if (mech.kind.costsLookup()) lookup_count += 1;
        if (mech.kind == .all and all == null) all = mech;
        if (mech.kind == .ptr) has_ptr = true;
    }

    if (all == null and redirect == null) {
        try warnings.add("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) |mech| {
        switch (mech.qualifier) {
            .pass => try warnings.add("\"+all\" explicitly allows every host on the internet to send mail as this domain — this defeats SPF entirely."),
            .neutral => try warnings.add("\"?all\" (Neutral) lets unmatched senders through with no protection. Prefer \"~all\" or \"-all\"."),
            else => {},
        }
    }
    if (has_ptr) {
        try warnings.add("The \"ptr\" mechanism is deprecated (RFC 7208 §5.5) and should not be used.");
    }
    if (redirect != null and all != null) {
        try warnings.add("A \"redirect=\" modifier is ignored when an \"all\" mechanism is present.");
    }
    if (lookup_count > 10) {
        try warnings.addFmt(
            "{d} DNS-lookup mechanisms — RFC 7208 §4.6.4 caps SPF at 10. Receivers that hit the cap return permerror and ignore the record.",
            .{lookup_count},
        );
    }

    return .{
        .valid = true,
        .version = version,
        .mechanisms = try mechanisms.toOwnedSlice(),
        .redirect = redirect,
        .exp = exp,
        .lookup_count = lookup_count,
        .record_count = spf_lines.items.len,
        .warnings = try warnings.toOwnedSlice(),
    };
}

// ---------------------------------------------------------------------------
// Tag lists — the `k=v; k=v` shape shared by DKIM and DMARC
// ---------------------------------------------------------------------------

/// Lowercased keys → trimmed values, first occurrence wins on the read side
/// because later duplicates overwrite exactly as JS `Map.set` does.
const TagMap = struct {
    map: std.StringHashMap([]const u8),

    fn deinit(self: *TagMap) void {
        var it = self.map.iterator();
        while (it.next()) |entry| self.map.allocator.free(entry.key_ptr.*);
        self.map.deinit();
    }

    fn get(self: TagMap, key: []const u8) ?[]const u8 {
        return self.map.get(key);
    }
};

/// Split a `;`-separated tag list. Values are slices of `txt`; keys are owned
/// (they must be lowercased). Malformed terms are reported, never fatal.
fn parseTags(allocator: Allocator, txt: []const u8, warnings: *Warnings) !TagMap {
    var tags = TagMap{ .map = std.StringHashMap([]const u8).init(allocator) };
    errdefer tags.deinit();

    var parts = std.mem.splitScalar(u8, txt, ';');
    while (parts.next()) |part| {
        const term = trim(stripQuotes(trim(part)));
        if (term.len == 0) continue;

        const eq = std.mem.indexOfScalar(u8, term, '=') orelse {
            try warnings.addFmt("Malformed tag \"{s}\" ignored.", .{term});
            continue;
        };
        if (eq == 0) {
            try warnings.addFmt("Malformed tag \"{s}\" ignored.", .{term});
            continue;
        }

        const raw_key = trim(term[0..eq]);
        const key = try allocator.alloc(u8, raw_key.len);
        errdefer allocator.free(key);
        for (raw_key, 0..) |c, i| key[i] = std.ascii.toLower(c);

        const gop = try tags.map.getOrPut(key);
        if (gop.found_existing) allocator.free(key); // keep the first key allocation
        gop.value_ptr.* = trim(term[eq + 1 ..]);
    }
    return tags;
}

// ---------------------------------------------------------------------------
// DKIM — RFC 6376
// ---------------------------------------------------------------------------

/// Decode base64 without failing on missing padding or embedded whitespace.
/// Returns null for input the decoder rejects (the TS catches `atob`).
fn decodeBase64Lenient(allocator: Allocator, b64: []const u8) !?[]u8 {
    var clean = std.ArrayList(u8).init(allocator);
    defer clean.deinit();
    for (b64) |c| {
        if (!std.ascii.isWhitespace(c)) try clean.append(c);
    }
    try clean.appendNTimes('=', (4 - (clean.items.len % 4)) % 4);

    const decoder = std.base64.standard.Decoder;
    const size = decoder.calcSizeForSlice(clean.items) catch return null;
    const out = try allocator.alloc(u8, size);
    errdefer allocator.free(out);
    decoder.decode(out, clean.items) catch {
        allocator.free(out);
        return null;
    };
    return out;
}

/// Parse a `<selector>._domainkey` TXT record. Pure.
pub fn parseDKIM(allocator: Allocator, txt: []const u8) !DKIMRecord {
    var warnings = Warnings.init(allocator);
    errdefer warnings.deinit();

    var tags = try parseTags(allocator, txt, &warnings);
    defer tags.deinit();

    var version: ?[]const u8 = null;
    errdefer if (version) |v| allocator.free(v);
    if (tags.get("v")) |v| {
        if (!std.ascii.eqlIgnoreCase(v, "DKIM1")) {
            try warnings.addFmt("Unusual version tag v={s} (expected DKIM1).", .{v});
        }
        const upper = try allocator.alloc(u8, v.len);
        for (v, 0..) |c, i| upper[i] = std.ascii.toUpper(c);
        version = upper;
    }

    const key_type = try allocator.dupe(u8, tags.get("k") orelse "rsa");
    errdefer allocator.free(key_type);

    const hashes = try splitTrimmed(allocator, tags.get("h") orelse "", ':');
    errdefer freeStrings(allocator, hashes);
    const services = try splitTrimmed(allocator, tags.get("s") orelse "", ':');
    errdefer freeStrings(allocator, services);
    const flags = try splitTrimmed(allocator, tags.get("t") orelse "", ':');
    errdefer freeStrings(allocator, flags);

    // Everything below can still return a record — only the p= tag decides validity.
    const p = tags.get("p");

    if (p == null) {
        try warnings.add("No p= tag — this record is not a usable DKIM key.");
        return .{
            .valid = false,
            .version = version,
            .key_type = key_type,
            .hashes = hashes,
            .services = services,
            .flags = flags,
            .warnings = try warnings.toOwnedSlice(),
        };
    }
    if (p.?.len == 0) {
        try warnings.add("p= is empty — the key is revoked. Receivers will treat mail signed with this selector as unsigned.");
        return .{
            .valid = true,
            .version = version,
            .key_type = key_type,
            .hashes = hashes,
            .services = services,
            .flags = flags,
            .warnings = try warnings.toOwnedSlice(),
        };
    }

    const decoded = try decodeBase64Lenient(allocator, p.?);
    defer if (decoded) |d| allocator.free(d);
    if (decoded == null) {
        try warnings.add("The p= value is not valid base64 — the key could not be read.");
        return .{
            .valid = true,
            .version = version,
            .key_type = key_type,
            .hashes = hashes,
            .services = services,
            .flags = flags,
            .warnings = try warnings.toOwnedSlice(),
        };
    }

    // Store the key whitespace-stripped, as the TS does.
    var public_key = std.ArrayList(u8).init(allocator);
    errdefer public_key.deinit();
    for (p.?) |c| {
        if (!std.ascii.isWhitespace(c)) try public_key.append(c);
    }

    var key_bits: ?usize = null;
    if (std.mem.eql(u8, key_type, "rsa")) {
        // SubjectPublicKeyInfo DER ≈ modulus bits/8 + ~24 bytes of ASN.1
        // overhead. The estimate is close enough to classify 512/1024/2048/4096.
        const bits = (decoded.?.len -| 24) * 8;
        key_bits = bits;
        if (bits < 1024) {
            try warnings.addFmt(
                "Weak RSA key (~{d} bits). Keys under 1024 bits are considered breakable; RFC 8301 discourages short keys.",
                .{bits},
            );
        } else if (bits < 2048) {
            try warnings.addFmt(
                "RSA key of ~{d} bits works today but is below the recommended 2048 bits (RFC 8301).",
                .{bits},
            );
        }
    }
    if (containsString(flags, "y")) {
        try warnings.add("t=y — the key is in test mode: receivers must treat signatures as if unsigned.");
    }
    if (containsString(flags, "s")) {
        try warnings.add("t=s — strict domain matching: the key cannot be used for subdomain signatures (informational).");
    }

    return .{
        .valid = true,
        .version = version,
        .key_type = key_type,
        .public_key = try public_key.toOwnedSlice(),
        .key_bits = key_bits,
        .hashes = hashes,
        .services = services,
        .flags = flags,
        .warnings = try warnings.toOwnedSlice(),
    };
}

// ---------------------------------------------------------------------------
// DMARC — RFC 7489
// ---------------------------------------------------------------------------

/// Read a `pct=` value the way `Number()` does for this tag: an empty string is
/// 0, a whole float such as `100.0` is accepted, anything else is rejected.
fn parsePercent(raw: []const u8) ?u8 {
    if (raw.len == 0) return 0;
    const n = std.fmt.parseFloat(f64, raw) catch return null;
    if (!std.math.isFinite(n)) return null;
    if (n != @floor(n)) return null;
    if (n < 0 or n > 100) return null;
    return @intFromFloat(n);
}

fn parseAlignment(raw: []const u8) ?Alignment {
    if (std.mem.eql(u8, raw, "r")) return .relaxed;
    if (std.mem.eql(u8, raw, "s")) return .strict;
    return null;
}

/// Lowercase into a small stack buffer; returns null when the value is longer
/// than any legal policy or alignment token could be.
fn lowerSmall(buf: []u8, s: []const u8) ?[]const u8 {
    if (s.len > buf.len) return null;
    for (s, 0..) |c, i| buf[i] = std.ascii.toLower(c);
    return buf[0..s.len];
}

/// Parse a `_dmarc` TXT record. Pure.
pub fn parseDMARC(allocator: Allocator, txt: []const u8) !DMARCRecord {
    var warnings = Warnings.init(allocator);
    errdefer warnings.deinit();

    var tags = try parseTags(allocator, txt, &warnings);
    defer tags.deinit();

    const version = tags.get("v");
    if (version == null) {
        try warnings.add("No v= tag — this is not a DMARC record.");
        return .{ .valid = false, .warnings = try warnings.toOwnedSlice() };
    }
    if (!std.ascii.eqlIgnoreCase(version.?, "DMARC1")) {
        try warnings.addFmt("Unknown version v={s} (expected DMARC1).", .{version.?});
        return .{ .valid = false, .warnings = try warnings.toOwnedSlice() };
    }

    var buf: [32]u8 = undefined;

    const raw_p = tags.get("p");
    if (raw_p == null or raw_p.?.len == 0) {
        try warnings.add("No p= policy tag — DMARC requires it.");
        return .{ .valid = false, .warnings = try warnings.toOwnedSlice() };
    }
    const lowered_p = lowerSmall(&buf, raw_p.?);
    const policy = if (lowered_p) |lp| DMARCPolicy.fromString(lp) else null;
    if (policy == null) {
        try warnings.addFmt(
            "Invalid policy p={s} (expected none, quarantine, or reject).",
            .{lowered_p orelse raw_p.?},
        );
        return .{ .valid = false, .warnings = try warnings.toOwnedSlice() };
    }

    var subdomain_policy: ?DMARCPolicy = null;
    if (tags.get("sp")) |raw_sp| {
        if (raw_sp.len > 0) {
            var sp_buf: [32]u8 = undefined;
            const lowered_sp = lowerSmall(&sp_buf, raw_sp);
            if (lowered_sp) |lsp| subdomain_policy = DMARCPolicy.fromString(lsp);
            if (subdomain_policy == null) {
                try warnings.addFmt(
                    "Invalid sp={s} ignored (expected none, quarantine, or reject).",
                    .{lowered_sp orelse raw_sp},
                );
            }
        }
    }

    var aggregate_uris: [][]const u8 = &.{};
    errdefer freeStrings(allocator, aggregate_uris);
    if (tags.get("rua")) |rua| {
        if (rua.len > 0) aggregate_uris = try splitTrimmed(allocator, rua, ',');
    }

    var forensic_uris: [][]const u8 = &.{};
    errdefer freeStrings(allocator, forensic_uris);
    if (tags.get("ruf")) |ruf| {
        if (ruf.len > 0) forensic_uris = try splitTrimmed(allocator, ruf, ',');
    }

    var percent: ?u8 = null;
    if (tags.get("pct")) |pct| {
        percent = parsePercent(pct);
        if (percent == null) try warnings.addFmt("Invalid pct={s} ignored (must be 0-100).", .{pct});
    }

    var dkim_alignment: ?Alignment = null;
    if (tags.get("adkim")) |adkim| {
        if (adkim.len > 0) {
            dkim_alignment = parseAlignment(adkim);
            if (dkim_alignment == null) {
                try warnings.addFmt("Invalid adkim={s} ignored (expected r or s).", .{adkim});
            }
        }
    }

    var spf_alignment: ?Alignment = null;
    if (tags.get("aspf")) |aspf| {
        if (aspf.len > 0) {
            spf_alignment = parseAlignment(aspf);
            if (spf_alignment == null) {
                try warnings.addFmt("Invalid aspf={s} ignored (expected r or s).", .{aspf});
            }
        }
    }

    // Policy guidance
    if (policy.? == .none) {
        try warnings.add("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 (aggregate_uris.len == 0) {
        try warnings.add("No rua= address — without aggregate reports you cannot see who is failing authentication. Add rua=mailto:reports@example.com.");
    } else if (forensic_uris.len > 0) {
        try warnings.add("ruf= (forensic reports) is supported by few receivers and may leak message content to the report address (informational).");
    }
    if (percent) |pct| {
        if (pct < 100 and policy.? != .none) {
            try warnings.addFmt(
                "pct={d} applies the policy to only {d}% of mail — the other {d}% is unaffected.",
                .{ pct, pct, 100 - pct },
            );
        }
    }

    return .{
        .valid = true,
        .policy = policy,
        .subdomain_policy = subdomain_policy,
        .aggregate_uris = aggregate_uris,
        .forensic_uris = forensic_uris,
        .percent = percent,
        .dkim_alignment = dkim_alignment,
        .spf_alignment = spf_alignment,
        .warnings = try warnings.toOwnedSlice(),
    };
}

// ---------------------------------------------------------------------------
// DNS-over-HTTPS lookup
// ---------------------------------------------------------------------------

const doh_endpoint = "https://cloudflare-dns.com/dns-query";

const DohAnswer = struct {
    name: []const u8 = "",
    type: u32 = 0,
    TTL: u32 = 0,
    data: []const u8 = "",
};

const DohResponse = struct {
    Status: ?i64 = null,
    Answer: ?[]DohAnswer = null,
};

/// Either the TXT strings for a name, or an owned message explaining why the
/// lookup did not produce any. Mirrors the TS `try/catch` around `queryTxt`.
const TxtLookup = union(enum) {
    ok: [][]const u8,
    failure: []const u8,

    fn deinit(self: TxtLookup, allocator: Allocator) void {
        switch (self) {
            .ok => |records| freeStrings(allocator, records),
            .failure => |message| allocator.free(message),
        }
    }
};

/// Percent-encode a query-string value (`encodeURIComponent`). Callers pass
/// validated hostnames, so this only ever has to escape the unexpected.
fn appendUriEncoded(out: *std.ArrayList(u8), s: []const u8) !void {
    for (s) |c| {
        const unreserved = std.ascii.isAlphanumeric(c) or
            c == '-' or c == '_' or c == '.' or c == '!' or
            c == '~' or c == '*' or c == '\'' or c == '(' or c == ')';
        if (unreserved) {
            try out.append(c);
        } else {
            try out.writer().print("%{X:0>2}", .{c});
        }
    }
}

/// Remove every occurrence of `needle` from `s`, returning an owned copy.
fn removeAll(allocator: Allocator, s: []const u8, needle: []const u8) ![]u8 {
    var out = std.ArrayList(u8).init(allocator);
    errdefer out.deinit();
    var rest = s;
    while (std.mem.indexOf(u8, rest, needle)) |idx| {
        try out.appendSlice(rest[0..idx]);
        rest = rest[idx + needle.len ..];
    }
    try out.appendSlice(rest);
    return out.toOwnedSlice();
}

/// Query TXT records for a name via Cloudflare's DoH JSON API. Returns unquoted
/// strings; an empty list means NXDOMAIN or no TXT data.
fn queryTxt(allocator: Allocator, name: []const u8) !TxtLookup {
    var url = std.ArrayList(u8).init(allocator);
    defer url.deinit();
    try url.appendSlice(doh_endpoint ++ "?name=");
    try appendUriEncoded(&url, name);
    try url.appendSlice("&type=TXT");

    var client = std.http.Client{ .allocator = allocator };
    defer client.deinit();

    var body = std.ArrayList(u8).init(allocator);
    defer body.deinit();

    const response = client.fetch(.{
        .method = .GET,
        .location = .{ .url = url.items },
        .extra_headers = &.{.{ .name = "Accept", .value = "application/dns-json" }},
        .response_storage = .{ .dynamic = &body },
        .max_append_size = 64 * 1024,
    }) catch {
        return .{ .failure = try allocator.dupe(u8, "DNS lookup failed.") };
    };

    if (response.status != .ok) {
        return .{ .failure = try std.fmt.allocPrint(
            allocator,
            "DNS resolver responded with HTTP {d}.",
            .{@intFromEnum(response.status)},
        ) };
    }

    const parsed = std.json.parseFromSlice(
        DohResponse,
        allocator,
        body.items,
        .{ .ignore_unknown_fields = true },
    ) catch {
        return .{ .failure = try allocator.dupe(u8, "DNS lookup failed.") };
    };
    defer parsed.deinit();

    const status = parsed.value.Status orelse 0;
    if (status == 3) return .{ .ok = &.{} }; // NXDOMAIN — no such domain
    if (status != 0) {
        return .{ .failure = try std.fmt.allocPrint(
            allocator,
            "DNS query failed with status {d}.",
            .{status},
        ) };
    }

    var records = std.ArrayList([]const u8).init(allocator);
    errdefer {
        for (records.items) |r| allocator.free(r);
        records.deinit();
    }
    for (parsed.value.Answer orelse &.{}) |answer| {
        if (answer.type != 16) continue; // TXT
        // Multi-chunk TXT arrives as `"part1" "part2"`: unquote, then splice.
        const joined = try removeAll(allocator, stripQuotes(answer.data), "\" \"");
        errdefer allocator.free(joined);
        try records.append(joined);
    }
    return .{ .ok = try records.toOwnedSlice() };
}

/// Join TXT strings with newlines, the shape the pure parsers expect.
fn joinLines(allocator: Allocator, records: [][]const u8) ![]u8 {
    return std.mem.join(allocator, "\n", records);
}

// ---------------------------------------------------------------------------
// Checks — lookup + parse + verdict
// ---------------------------------------------------------------------------

const invalid_domain_message = "Enter a valid domain, e.g. example.com.";
const invalid_selector_message = "Enter a valid selector (letters, digits, dots, hyphens, underscores).";

/// Look up and evaluate a domain's SPF record.
pub fn checkSPF(allocator: Allocator, domain: []const u8) !SPFResult {
    const host = try normalizeDomain(allocator, domain);
    defer allocator.free(host);
    if (!isDomainLike(host)) {
        return .{ .status = .fail, .found = false, .message = try allocator.dupe(u8, invalid_domain_message) };
    }

    const lookup = try queryTxt(allocator, host);
    defer lookup.deinit(allocator);
    const records = switch (lookup) {
        .failure => |message| return .{
            .status = .fail,
            .found = false,
            .message = try allocator.dupe(u8, message),
        },
        .ok => |records| records,
    };

    const joined = try joinLines(allocator, records);
    defer allocator.free(joined);

    var record = try parseSPF(allocator, joined);
    errdefer record.deinit(allocator);

    if (!record.valid) {
        record.deinit(allocator);
        return .{
            .status = .fail,
            .found = false,
            .message = try std.fmt.allocPrint(
                allocator,
                "No SPF record found for {s}. Receivers cannot verify which servers may send mail for it.",
                .{host},
            ),
        };
    }

    const status: CheckStatus = if (record.warnings.len > 0) .warn else .pass;
    return .{
        .status = status,
        .found = true,
        .record = record,
        .message = if (status == .pass)
            try allocator.dupe(u8, "SPF record found and looks healthy.")
        else
            null,
    };
}

/// Look up and evaluate a domain's DKIM public key for one selector.
pub fn checkDKIM(allocator: Allocator, domain: []const u8, selector: []const u8) !DKIMResult {
    const host = try normalizeDomain(allocator, domain);
    defer allocator.free(host);
    if (!isDomainLike(host)) {
        return .{ .status = .fail, .found = false, .message = try allocator.dupe(u8, invalid_domain_message) };
    }

    const trimmed = trim(selector);
    const sel = try allocator.alloc(u8, trimmed.len);
    defer allocator.free(sel);
    for (trimmed, 0..) |c, i| sel[i] = std.ascii.toLower(c);
    if (!isValidSelector(sel)) {
        return .{ .status = .fail, .found = false, .message = try allocator.dupe(u8, invalid_selector_message) };
    }

    const name = try std.fmt.allocPrint(allocator, "{s}._domainkey.{s}", .{ sel, host });
    defer allocator.free(name);

    const lookup = try queryTxt(allocator, name);
    defer lookup.deinit(allocator);
    const records = switch (lookup) {
        .failure => |message| return .{
            .status = .fail,
            .found = false,
            .message = try allocator.dupe(u8, message),
        },
        .ok => |records| records,
    };

    const joined = try joinLines(allocator, records);
    defer allocator.free(joined);

    var record = try parseDKIM(allocator, joined);
    errdefer record.deinit(allocator);

    if (!record.valid) {
        record.deinit(allocator);
        return .{
            .status = .fail,
            .found = false,
            .message = try std.fmt.allocPrint(
                allocator,
                "No DKIM record found at {s}. Try another selector — only one is checked per lookup.",
                .{name},
            ),
        };
    }

    var revoked = false;
    for (record.warnings) |w| {
        if (std.mem.indexOf(u8, w, "revoked") != null) revoked = true;
    }
    const status: CheckStatus = if (revoked) .fail else if (record.warnings.len > 0) .warn else .pass;
    return .{ .status = status, .found = true, .record = record };
}

/// Look up and evaluate a domain's DMARC policy.
pub fn checkDMARC(allocator: Allocator, domain: []const u8) !DMARCResult {
    const host = try normalizeDomain(allocator, domain);
    defer allocator.free(host);
    if (!isDomainLike(host)) {
        return .{ .status = .fail, .found = false, .message = try allocator.dupe(u8, invalid_domain_message) };
    }

    const name = try std.fmt.allocPrint(allocator, "_dmarc.{s}", .{host});
    defer allocator.free(name);

    const lookup = try queryTxt(allocator, name);
    defer lookup.deinit(allocator);
    const records = switch (lookup) {
        .failure => |message| return .{
            .status = .fail,
            .found = false,
            .message = try allocator.dupe(u8, message),
        },
        .ok => |records| records,
    };

    const joined = try joinLines(allocator, records);
    defer allocator.free(joined);

    var record = try parseDMARC(allocator, joined);
    errdefer record.deinit(allocator);

    if (!record.valid) {
        record.deinit(allocator);
        return .{
            .status = .fail,
            .found = false,
            .message = try std.fmt.allocPrint(
                allocator,
                "No DMARC record found at {s}. Receivers have no policy to apply when SPF or DKIM fails.",
                .{name},
            ),
        };
    }

    const status: CheckStatus = if (record.warnings.len > 0) .warn else .pass;
    return .{ .status = status, .found = true, .record = record };
}

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 →