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 →