Skip to content

DKIM / SPF / DMARC Builder & Checker — Swift 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 Swift 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: Swift 5.9+ (Foundation only)
// Ported from src/lib/dkim-spf-dmarc.ts
// display source — part of CosmoDev's polyglot tool pages
//
// Two layers, mirroring the TypeScript reference:
//   - Pure parsers (parseSPF / parseDKIM / parseDMARC) — deterministic, never
//     throw, testable without network.
//   - Check functions (checkSPF / checkDKIM / checkDMARC) — one DNS-over-HTTPS
//     request to Cloudflare's public resolver, then a pure parse.
//
// Specs: SPF RFC 7208, DKIM RFC 6376, DMARC RFC 7489.

import Foundation

#if canImport(FoundationNetworking)
    import FoundationNetworking // URLSession on Linux
#endif

// MARK: - Types

enum CheckStatus: String {
    case pass, warn, fail
}

enum SPFQualifier: String {
    case allow = "+"
    case hardFail = "-"
    case softFail = "~"
    case neutral = "?"
}

enum SPFMechanismKind: String {
    case all, include, a, mx, ip4, ip6, exists, ptr

    /// Mechanisms that cost a DNS query under RFC 7208 §4.6.4 (limit 10).
    var costsLookup: Bool {
        switch self {
        case .include, .a, .mx, .exists, .ptr: return true
        case .all, .ip4, .ip6: return false
        }
    }
}

struct SPFMechanism {
    let qualifier: SPFQualifier
    let kind: SPFMechanismKind
    /// Domain, IP, or CIDR argument after the colon
    /// (e.g. `_spf.google.com`, `192.0.2.0/24`).
    let value: String?
}

struct SPFRecord {
    var valid: Bool
    var version: String?
    var mechanisms: [SPFMechanism] = []
    var redirect: String?
    var exp: String?
    /// Mechanisms that cost a DNS query under RFC 7208 §4.6.4 (limit 10).
    var lookupCount: Int = 0
    /// Number of `v=spf1` records found (more than one is a hard error for receivers).
    var recordCount: Int = 0
    var warnings: [String] = []
}

struct DKIMRecord {
    var valid: Bool = true
    var version: String?
    var keyType: String
    /// Public key, base64, whitespace-stripped.
    var publicKey: String?
    /// Approximate modulus size in bits (RSA only; derived from DER byte length).
    var keyBits: Int?
    var hashes: [String]
    var services: [String]
    var flags: [String]
    var warnings: [String]
}

enum DMARCPolicy: String {
    case none, quarantine, reject
}

enum DMARCAlignment: String {
    case relaxed = "r"
    case strict = "s"
}

struct DMARCRecord {
    var valid: Bool = true
    var policy: DMARCPolicy?
    var subdomainPolicy: DMARCPolicy?
    var aggregateUris: [String] = []
    var forensicUris: [String] = []
    var percent: Int?
    var dkimAlignment: DMARCAlignment?
    var spfAlignment: DMARCAlignment?
    var warnings: [String] = []
}

struct SPFResult {
    let status: CheckStatus
    let found: Bool
    var record: SPFRecord?
    var message: String?
}

struct DKIMResult {
    let status: CheckStatus
    let found: Bool
    var record: DKIMRecord?
    var message: String?
}

struct DMARCResult {
    let status: CheckStatus
    let found: Bool
    var record: DMARCRecord?
    var message: String?
}

// MARK: - Shared helpers

private extension String {
    var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }

    func replacingPattern(_ pattern: String, with template: String, caseInsensitive: Bool = true) -> String {
        var options: String.CompareOptions = [.regularExpression]
        if caseInsensitive { options.insert(.caseInsensitive) }
        return replacingOccurrences(of: pattern, with: template, options: options)
    }

    func matches(_ pattern: String, caseInsensitive: Bool = false) -> Bool {
        var options: String.CompareOptions = [.regularExpression]
        if caseInsensitive { options.insert(.caseInsensitive) }
        return range(of: pattern, options: options) != nil
    }

    /// `/^"(.*)"$/` — TXT chunks arrive quoted from most resolvers.
    var unquoted: String {
        guard count >= 2, hasPrefix("\""), hasSuffix("\"") else { return self }
        return String(dropFirst().dropLast())
    }

    /// Strip every whitespace character — the JS `replace(/\s+/g, '')`.
    var withoutWhitespace: String {
        components(separatedBy: .whitespacesAndNewlines).joined()
    }
}

/// Mimic JavaScript's `Number(string)` closely enough for the `pct=` tag:
/// whitespace is trimmed, an empty string is 0, anything non-numeric is NaN (nil).
private func jsNumber(_ text: String) -> Double? {
    let trimmed = text.trimmed
    if trimmed.isEmpty { return 0 }
    return Double(trimmed)
}

/// Strip a leading scheme, userinfo, path, query, port, and trailing dot from user input.
func normalizeDomain(_ raw: String) -> String {
    var s = raw.trimmed
    s = s.replacingPattern(#"^[a-z][a-z0-9+.-]*://"#, with: "") // scheme://
    s = s.replacingPattern(#"^mailto:"#, with: "") // mailto:user@domain
    if let at = s.lastIndex(of: "@") { s = String(s[s.index(after: at)...]) } // keep host of user@host
    s = s.components(separatedBy: "/")[0] // drop path
    s = s.components(separatedBy: "?")[0] // drop query
    s = s.components(separatedBy: ":")[0] // drop port
    s = s.replacingPattern(#"\.+$"#, with: "") // trailing dot(s)
    return s.lowercased()
}

private let DOMAIN_PATTERN = #"^(?:[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).
func isDomainLike(_ domain: String) -> Bool {
    // Case-sensitive, exactly as in the TS reference (callers normalize first).
    domain.matches(DOMAIN_PATTERN) && domain.count <= 253
}

private let SELECTOR_PATTERN = #"^[a-z0-9][a-z0-9._-]*$"#

/// True when the selector is a safe single DNS label chain (no spaces, no traversal).
func isValidSelector(_ selector: String) -> Bool {
    let s = selector.trimmed
    return !s.isEmpty && s.count <= 100 && s.matches(SELECTOR_PATTERN, caseInsensitive: true) && !s.contains("..")
}

// MARK: - SPF (RFC 7208)

/// Parse one or more (newline-joined) TXT record strings for SPF. Pure.
func parseSPF(_ txt: String) -> SPFRecord {
    let lines = txt
        .components(separatedBy: "\n")
        .map { $0.trimmed.unquoted }
        .filter { !$0.isEmpty }
    let spfLines = lines.filter { $0.matches(#"^v=spf1(?:\s|$)"#, caseInsensitive: true) }
    var warnings: [String] = []

    if spfLines.isEmpty {
        return SPFRecord(valid: false, warnings: ["No v=spf1 record found in the supplied text."])
    }
    if spfLines.count > 1 {
        warnings.append("\(spfLines.count) SPF records found — RFC 7208 allows exactly one. Receivers treat this as a permanent error and ignore SPF for the whole domain.")
    }

    let terms = spfLines[0].components(separatedBy: .whitespaces).filter { !$0.isEmpty }
    let version = terms.first
    var mechanisms: [SPFMechanism] = []
    var redirect: String?
    var exp: String?

    for term in terms.dropFirst() {
        // Modifiers use '=': redirect= and exp=
        if let modifier = captures(#"^([a-z][a-z0-9-]*)=(.*)$"#, term) {
            let name = (modifier[1] ?? "").lowercased()
            if name == "redirect" {
                redirect = modifier[2]
            } else if name == "exp" {
                exp = modifier[2]
            } else {
                warnings.append("Unknown modifier \"\(term)\" ignored.")
            }
            continue
        }

        guard let m = captures(#"^([+\-~?])?([a-z0-9]+)(?::(.*))?$"#, term) else {
            warnings.append("Unrecognized term \"\(term)\" ignored.")
            continue
        }

        let qualifier = SPFQualifier(rawValue: m[1] ?? "+") ?? .allow
        let kindToken = (m[2] ?? "").lowercased()
        let value = m[3]

        guard let kind = SPFMechanismKind(rawValue: kindToken) else {
            warnings.append("Unknown mechanism \"\(term)\" ignored.")
            continue
        }
        if (value == nil || value!.isEmpty), kind == .include || kind == .exists {
            warnings.append("Mechanism \"\(term)\" is missing its required value.")
            continue
        }
        mechanisms.append(SPFMechanism(qualifier: qualifier, kind: kind, value: value))
    }

    let lookupCount = mechanisms.filter(\.kind.costsLookup).count + (redirect != nil ? 1 : 0)
    let all = mechanisms.first { $0.kind == .all }

    if all == nil, redirect == nil {
        warnings.append("No \"all\" mechanism and no \"redirect=\" — unmatched senders get a Neutral result, so anyone can still send mail that looks like this domain.")
    }
    if let all, all.qualifier == .allow {
        warnings.append("\"+all\" explicitly allows every host on the internet to send mail as this domain — this defeats SPF entirely.")
    } else if let all, all.qualifier == .neutral {
        warnings.append("\"?all\" (Neutral) lets unmatched senders through with no protection. Prefer \"~all\" or \"-all\".")
    }
    if mechanisms.contains(where: { $0.kind == .ptr }) {
        warnings.append("The \"ptr\" mechanism is deprecated (RFC 7208 §5.5) and should not be used.")
    }
    if redirect != nil, all != nil {
        warnings.append("A \"redirect=\" modifier is ignored when an \"all\" mechanism is present.")
    }
    if lookupCount > 10 {
        warnings.append("\(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 SPFRecord(
        valid: true,
        version: version,
        mechanisms: mechanisms,
        redirect: redirect,
        exp: exp,
        lookupCount: lookupCount,
        recordCount: spfLines.count,
        warnings: warnings
    )
}

/// Capture groups of the first match (index 0 = whole match), or nil when no match.
private func captures(_ pattern: String, _ text: String) -> [String?]? {
    guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil }
    let ns = text as NSString
    guard let match = regex.firstMatch(in: text, range: NSRange(location: 0, length: ns.length)) else { return nil }
    return (0..<match.numberOfRanges).map { index in
        let range = match.range(at: index)
        return range.location == NSNotFound ? nil : ns.substring(with: range)
    }
}

// MARK: - DKIM (RFC 6376)

/// Split a `k=v; k=v` TXT record into a lowercased tag map, collecting
/// warnings for malformed terms. Shared by DKIM and DMARC.
private func parseTagList(_ txt: String, _ warnings: inout [String]) -> [String: String] {
    var tags: [String: String] = [:]
    for part in txt.components(separatedBy: ";") {
        let term = part.trimmed.unquoted.trimmed
        if term.isEmpty { continue }
        guard let eq = term.firstIndex(of: "="), eq != term.startIndex else {
            warnings.append("Malformed tag \"\(term)\" ignored.")
            continue
        }
        let key = String(term[term.startIndex..<eq]).trimmed.lowercased()
        tags[key] = String(term[term.index(after: eq)...]).trimmed
    }
    return tags
}

/// Split a colon-separated tag value (`h=sha256:sha1`) into non-empty parts.
private func colonList(_ value: String?) -> [String] {
    (value ?? "").components(separatedBy: ":").map(\.trimmed).filter { !$0.isEmpty }
}

/// Decode base64 to bytes without throwing on bad input.
private func decodeBase64Lenient(_ b64: String) -> Data? {
    // Callers pass trimmed, non-empty values (empty p= is handled before this).
    let clean = b64.withoutWhitespace
    let padded = clean + String(repeating: "=", count: (4 - (clean.count % 4)) % 4)
    return Data(base64Encoded: padded)
}

/// Parse a `<selector>._domainkey` TXT record. Pure.
func parseDKIM(_ txt: String) -> DKIMRecord {
    var warnings: [String] = []
    let tags = parseTagList(txt, &warnings)

    let version = tags["v"]
    if let version, version.uppercased() != "DKIM1" {
        warnings.append("Unusual version tag v=\(version) (expected DKIM1).")
    }

    let keyType = tags["k"] ?? "rsa"
    let p = tags["p"]

    var record = DKIMRecord(
        keyType: keyType,
        hashes: colonList(tags["h"]),
        services: colonList(tags["s"]),
        flags: colonList(tags["t"]),
        warnings: warnings
    )
    if let version { record.version = version.uppercased() }

    guard let p else {
        record.valid = false
        record.warnings.append("No p= tag — this record is not a usable DKIM key.")
        return record
    }
    if p.isEmpty {
        record.warnings.append("p= is empty — the key is revoked. Receivers will treat mail signed with this selector as unsigned.")
        return record
    }
    guard let decoded = decodeBase64Lenient(p) else {
        record.warnings.append("The p= value is not valid base64 — the key could not be read.")
        return record
    }
    record.publicKey = p.withoutWhitespace

    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.
        let bits = max(0, decoded.count - 24) * 8
        record.keyBits = bits
        if bits < 1024 {
            record.warnings.append("Weak RSA key (~\(bits) bits). Keys under 1024 bits are considered breakable; RFC 8301 discourages short keys.")
        } else if bits < 2048 {
            record.warnings.append("RSA key of ~\(bits) bits works today but is below the recommended 2048 bits (RFC 8301).")
        }
    }
    if record.flags.contains("y") {
        record.warnings.append("t=y — the key is in test mode: receivers must treat signatures as if unsigned.")
    }
    if record.flags.contains("s") {
        record.warnings.append("t=s — strict domain matching: the key cannot be used for subdomain signatures (informational).")
    }
    return record
}

// MARK: - DMARC (RFC 7489)

/// Parse a `_dmarc` TXT record. Pure.
func parseDMARC(_ txt: String) -> DMARCRecord {
    var warnings: [String] = []
    let tags = parseTagList(txt, &warnings)

    var record = DMARCRecord(warnings: warnings)

    guard let version = tags["v"] else {
        record.valid = false
        record.warnings.append("No v= tag — this is not a DMARC record.")
        return record
    }
    guard version.uppercased() == "DMARC1" else {
        record.valid = false
        record.warnings.append("Unknown version v=\(version) (expected DMARC1).")
        return record
    }

    guard let rawPolicy = tags["p"]?.lowercased(), !rawPolicy.isEmpty else {
        record.valid = false
        record.warnings.append("No p= policy tag — DMARC requires it.")
        return record
    }
    guard let policy = DMARCPolicy(rawValue: rawPolicy) else {
        record.valid = false
        record.warnings.append("Invalid policy p=\(rawPolicy) (expected none, quarantine, or reject).")
        return record
    }
    record.policy = policy

    if let rawSub = tags["sp"]?.lowercased(), !rawSub.isEmpty {
        if let sub = DMARCPolicy(rawValue: rawSub) {
            record.subdomainPolicy = sub
        } else {
            record.warnings.append("Invalid sp=\(rawSub) ignored (expected none, quarantine, or reject).")
        }
    }

    if let rua = tags["rua"], !rua.isEmpty {
        record.aggregateUris = rua.components(separatedBy: ",").map(\.trimmed).filter { !$0.isEmpty }
    }
    if let ruf = tags["ruf"], !ruf.isEmpty {
        record.forensicUris = ruf.components(separatedBy: ",").map(\.trimmed).filter { !$0.isEmpty }
    }

    if let pct = tags["pct"] {
        let n = jsNumber(pct)
        if let n, n.rounded() == n, n >= 0, n <= 100 {
            record.percent = Int(n)
        } else {
            record.warnings.append("Invalid pct=\(pct) ignored (must be 0-100).")
        }
    }

    if let adkim = tags["adkim"], !adkim.isEmpty {
        if let alignment = DMARCAlignment(rawValue: adkim) {
            record.dkimAlignment = alignment
        } else {
            record.warnings.append("Invalid adkim=\(adkim) ignored (expected r or s).")
        }
    }
    if let aspf = tags["aspf"], !aspf.isEmpty {
        if let alignment = DMARCAlignment(rawValue: aspf) {
            record.spfAlignment = alignment
        } else {
            record.warnings.append("Invalid aspf=\(aspf) ignored (expected r or s).")
        }
    }

    // Policy guidance
    if record.policy == DMARCPolicy.none {
        record.warnings.append("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.isEmpty {
        record.warnings.append("No rua= address — without aggregate reports you cannot see who is failing authentication. Add rua=mailto:reports@example.com.")
    } else if !record.forensicUris.isEmpty {
        record.warnings.append("ruf= (forensic reports) is supported by few receivers and may leak message content to the report address (informational).")
    }
    if let percent = record.percent, percent < 100, record.policy != DMARCPolicy.none {
        record.warnings.append("pct=\(percent) applies the policy to only \(percent)% of mail — the other \(100 - percent)% is unaffected.")
    }
    return record
}

// MARK: - DNS-over-HTTPS lookup

private let DOH_ENDPOINT = "https://cloudflare-dns.com/dns-query"

enum DnsError: LocalizedError {
    case badResponse(Int)
    case queryFailed(Int)
    case badRequest

    var errorDescription: String? {
        switch self {
        case let .badResponse(code): return "DNS resolver responded with HTTP \(code)."
        case let .queryFailed(status): return "DNS query failed with status \(status)."
        case .badRequest: return "Could not build the DNS query URL."
        }
    }
}

private struct DohAnswer: Decodable {
    let type: Int
    let data: String
}

private struct DohResponse: Decodable {
    let Status: Int?
    let Answer: [DohAnswer]?
}

/// Query TXT records for a name via Cloudflare's DoH JSON API. Returns unquoted strings.
func queryTxt(_ name: String) async throws -> [String] {
    guard var components = URLComponents(string: DOH_ENDPOINT) else { throw DnsError.badRequest }
    components.queryItems = [URLQueryItem(name: "name", value: name), URLQueryItem(name: "type", value: "TXT")]
    guard let url = components.url else { throw DnsError.badRequest }

    var request = URLRequest(url: url)
    request.setValue("application/dns-json", forHTTPHeaderField: "Accept")

    let (data, response) = try await URLSession.shared.data(for: request)
    if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
        throw DnsError.badResponse(http.statusCode)
    }

    let json = try JSONDecoder().decode(DohResponse.self, from: data)
    if json.Status == 3 { return [] } // NXDOMAIN — no such domain
    if json.Status != 0 { throw DnsError.queryFailed(json.Status ?? -1) }

    return (json.Answer ?? [])
        .filter { $0.type == 16 }
        .map { $0.data.unquoted.replacingOccurrences(of: "\" \"", with: "") } // multi-chunk TXT: "part1" "part2"
}

// MARK: - Checks

/// Look up and evaluate a domain's SPF record.
func checkSPF(_ domain: String) async -> SPFResult {
    let host = normalizeDomain(domain)
    guard isDomainLike(host) else {
        return SPFResult(status: .fail, found: false, message: "Enter a valid domain, e.g. example.com.")
    }
    do {
        let record = parseSPF(try await queryTxt(host).joined(separator: "\n"))
        guard record.valid else {
            return SPFResult(status: .fail, found: false, message: "No SPF record found for \(host). Receivers cannot verify which servers may send mail for it.")
        }
        let status: CheckStatus = record.warnings.isEmpty ? .pass : .warn
        return SPFResult(
            status: status,
            found: true,
            record: record,
            message: status == .pass ? "SPF record found and looks healthy." : nil
        )
    } catch {
        return SPFResult(status: .fail, found: false, message: error.localizedDescription)
    }
}

/// Look up and evaluate a domain's DKIM public key for one selector.
func checkDKIM(_ domain: String, selector: String) async -> DKIMResult {
    let host = normalizeDomain(domain)
    guard isDomainLike(host) else {
        return DKIMResult(status: .fail, found: false, message: "Enter a valid domain, e.g. example.com.")
    }
    let sel = selector.trimmed.lowercased()
    guard isValidSelector(sel) else {
        return DKIMResult(status: .fail, found: false, message: "Enter a valid selector (letters, digits, dots, hyphens, underscores).")
    }
    do {
        let record = parseDKIM(try await queryTxt("\(sel)._domainkey.\(host)").joined(separator: "\n"))
        guard record.valid else {
            return DKIMResult(status: .fail, found: false, message: "No DKIM record found at \(sel)._domainkey.\(host). Try another selector — only one is checked per lookup.")
        }
        let revoked = record.warnings.contains { $0.contains("revoked") }
        let status: CheckStatus = revoked ? .fail : (record.warnings.isEmpty ? .pass : .warn)
        return DKIMResult(status: status, found: true, record: record)
    } catch {
        return DKIMResult(status: .fail, found: false, message: error.localizedDescription)
    }
}

/// Look up and evaluate a domain's DMARC policy.
func checkDMARC(_ domain: String) async -> DMARCResult {
    let host = normalizeDomain(domain)
    guard isDomainLike(host) else {
        return DMARCResult(status: .fail, found: false, message: "Enter a valid domain, e.g. example.com.")
    }
    do {
        let record = parseDMARC(try await queryTxt("_dmarc.\(host)").joined(separator: "\n"))
        guard record.valid else {
            return DMARCResult(status: .fail, found: false, message: "No DMARC record found at _dmarc.\(host). Receivers have no policy to apply when SPF or DKIM fails.")
        }
        let status: CheckStatus = record.warnings.isEmpty ? .pass : .warn
        return DMARCResult(status: status, found: true, record: record)
    } catch {
        return DMARCResult(status: .fail, found: false, message: error.localizedDescription)
    }
}

// MARK: - Demo

func demo() {
    // Pure parsing — no network required.
    let spf = parseSPF("v=spf1 include:_spf.google.com include:sendgrid.net ip4:192.0.2.0/24 ~all")
    print("SPF valid: \(spf.valid), lookups: \(spf.lookupCount), mechanisms: \(spf.mechanisms.count)")
    spf.warnings.forEach { print("  ! \($0)") }

    let dmarc = parseDMARC("v=DMARC1; p=quarantine; pct=50; rua=mailto:reports@example.com; adkim=s")
    print("DMARC policy: \(dmarc.policy?.rawValue ?? "—"), pct: \(dmarc.percent.map(String.init) ?? "—")")
    dmarc.warnings.forEach { print("  ! \($0)") }

    let dkim = parseDKIM("v=DKIM1; k=rsa; p=; t=y")
    print("DKIM valid: \(dkim.valid), key type: \(dkim.keyType)")
    dkim.warnings.forEach { print("  ! \($0)") }
}

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 →