Skip to content

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

// Email authentication (SPF / DKIM / DMARC) record parsing and lookup.
//
// Language: Kotlin 1.9+ (JVM), standard library + kotlinx.serialization-json.
// Ported from src/lib/dkim-spf-dmarc.ts — display source, part of CosmoDev's
// polyglot tool pages. Functionally equivalent to the TS reference: same
// parsers, same warnings, same DNS-over-HTTPS endpoint (Cloudflare's public
// resolver, JSON API).
//
// Two layers, mirroring the TS reference:
//   - Pure parsers (parseSPF / parseDKIM / parseDMARC) — deterministic, never
//     throw, unit-testable without network.
//   - Check functions (checkSPF / checkDKIM / checkDMARC) — one DNS-over-HTTPS
//     fetch via java.net.http.HttpClient, then a pure parse.
//
// Specs: SPF RFC 7208, DKIM RFC 6376, DMARC RFC 7489.

import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.util.Base64
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive

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

enum class CheckStatus { PASS, WARN, FAIL }

enum class SPFQualifier(val token: String) { PASS("+"), FAIL("-"), SOFTFAIL("~"), NEUTRAL("?") }

data class SPFMechanism(
    val qualifier: SPFQualifier,
    val kind: String, // 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`). */
    val value: String? = null,
)

data class SPFRecord(
    val valid: Boolean,
    val version: String? = null,
    val mechanisms: List<SPFMechanism> = emptyList(),
    val redirect: String? = null,
    val exp: String? = null,
    /** Mechanisms that cost a DNS query under RFC 7208 §4.6.4 (limit 10). */
    val lookupCount: Int = 0,
    /** Number of `v=spf1` records found (more than one is a hard error for receivers). */
    val recordCount: Int = 0,
    val warnings: List<String> = emptyList(),
)

data class DKIMRecord(
    val valid: Boolean,
    val version: String? = null,
    val keyType: String,
    /** Public key, base64, whitespace-stripped. */
    val publicKey: String? = null,
    /** Approximate modulus size in bits (RSA only; derived from DER byte length). */
    val keyBits: Int? = null,
    val hashes: List<String> = emptyList(),
    val services: List<String> = emptyList(),
    val flags: List<String> = emptyList(),
    val warnings: List<String> = emptyList(),
)

enum class DMARCPolicy(val token: String) { NONE("none"), QUARANTINE("quarantine"), REJECT("reject") }

data class DMARCRecord(
    val valid: Boolean,
    val policy: DMARCPolicy? = null,
    val subdomainPolicy: DMARCPolicy? = null,
    val aggregateUris: List<String> = emptyList(),
    val forensicUris: List<String> = emptyList(),
    val percent: Int? = null,
    val dkimAlignment: String? = null, // r | s
    val spfAlignment: String? = null, // r | s
    val warnings: List<String> = emptyList(),
)

data class SPFResult(val status: CheckStatus, val found: Boolean, val record: SPFRecord? = null, val message: String? = null)
data class DKIMResult(val status: CheckStatus, val found: Boolean, val record: DKIMRecord? = null, val message: String? = null)
data class DMARCResult(val status: CheckStatus, val found: Boolean, val record: DMARCRecord? = null, val message: String? = null)

// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------

private val SCHEME_RE = Regex("^[a-z][a-z0-9+.-]*://", RegexOption.IGNORE_CASE)
private val MAILTO_RE = Regex("^mailto:", RegexOption.IGNORE_CASE)
private val TRAILING_DOTS_RE = Regex("\\.+$")

/** Strip a leading scheme, userinfo, path, query, port, and trailing dot from user input. */
fun normalizeDomain(raw: String): String {
    var s = raw.trim()
    s = s.replaceFirst(SCHEME_RE, "") // scheme://
    s = s.replaceFirst(MAILTO_RE, "") // mailto:user@domain
    if (s.contains('@')) s = s.substring(s.lastIndexOf('@') + 1) // keep host of user@host
    s = s.substringBefore('/') // drop path
    s = s.substringBefore('?') // drop query
    s = s.substringBefore(':') // drop port
    s = s.replaceFirst(TRAILING_DOTS_RE, "") // trailing dot(s)
    return s.lowercase()
}

private val DOMAIN_RE = Regex("^(?:[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). */
fun isDomainLike(domain: String): Boolean =
    DOMAIN_RE.matches(domain) && domain.length <= 253

private val SELECTOR_RE = Regex("^[a-z0-9][a-z0-9._-]*$", RegexOption.IGNORE_CASE)

/** True when the selector is a safe single DNS label chain (no spaces, no traversal). */
fun isValidSelector(selector: String): Boolean {
    val s = selector.trim()
    return s.isNotEmpty() && s.length <= 100 && SELECTOR_RE.matches(s) && !s.contains("..")
}

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

private val SPF_LOOKUP_KINDS = setOf("include", "a", "mx", "exists", "ptr")
private val SPF_KINDS = setOf("all", "include", "a", "mx", "ip4", "ip6", "exists", "ptr")
private val QUOTED_RE = Regex("^\"(.*)\"$")
private val MODIFIER_RE = Regex("^([a-z][a-z0-9-]*)=(.*)$", RegexOption.IGNORE_CASE)
private val MECHANISM_RE = Regex("^([+~?-])?([a-z0-9]+)(?::(.*))?$", RegexOption.IGNORE_CASE)
private val SPF_VERSION_RE = Regex("^v=spf1(?:\\s|$)", RegexOption.IGNORE_CASE)

/** Parse one or more (newline-joined) TXT record strings for SPF. Pure. */
fun parseSPF(txt: String): SPFRecord {
    val lines = txt.split('\n')
        .map { it.trim().replace(QUOTED_RE, "$1") }
        .filter { it.isNotEmpty() }
    val spfLines = lines.filter { SPF_VERSION_RE.containsMatchIn(it) }
    val warnings = mutableListOf<String>()

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

    val terms = spfLines[0].split(Regex("\\s+"))
    val version = terms[0]
    val mechanisms = mutableListOf<SPFMechanism>()
    var redirect: String? = null
    var exp: String? = null

    for (term in terms.drop(1)) {
        // Modifiers use '=': redirect= and exp=
        val modifier = MODIFIER_RE.find(term)
        if (modifier != null) {
            when (modifier.groupValues[1].lowercase()) {
                "redirect" -> redirect = modifier.groupValues[2]
                "exp" -> exp = modifier.groupValues[2]
                else -> warnings.add("Unknown modifier \"$term\" ignored.")
            }
            continue
        }
        val m = MECHANISM_RE.find(term)
        if (m == null) {
            warnings.add("Unrecognized term \"$term\" ignored.")
            continue
        }
        val qualifier = when (m.groupValues[1].ifEmpty { "+" }) {
            "-" -> SPFQualifier.FAIL
            "~" -> SPFQualifier.SOFTFAIL
            "?" -> SPFQualifier.NEUTRAL
            else -> SPFQualifier.PASS
        }
        val kind = m.groupValues[2].lowercase()
        val value = m.groupValues[3].ifEmpty { null }
        when {
            kind !in SPF_KINDS -> warnings.add("Unknown mechanism \"$term\" ignored.")
            value == null && (kind == "include" || kind == "exists") ->
                warnings.add("Mechanism \"$term\" is missing its required value.")
            else -> mechanisms.add(SPFMechanism(qualifier, kind, value))
        }
    }

    val lookupCount = mechanisms.count { it.kind in SPF_LOOKUP_KINDS } + (redirect?.let { 1 } ?: 0)

    val all = mechanisms.firstOrNull { it.kind == "all" }
    if (all == null && redirect == null) {
        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?.qualifier == SPFQualifier.PASS) {
        warnings.add("\"+all\" explicitly allows every host on the internet to send mail as this domain — this defeats SPF entirely.")
    } else if (all?.qualifier == SPFQualifier.NEUTRAL) {
        warnings.add("\"?all\" (Neutral) lets unmatched senders through with no protection. Prefer \"~all\" or \"-all\".")
    }
    if (mechanisms.any { it.kind == "ptr" }) {
        warnings.add("The \"ptr\" mechanism is deprecated (RFC 7208 §5.5) and should not be used.")
    }
    if (redirect != null && all != null) {
        warnings.add("A \"redirect=\" modifier is ignored when an \"all\" mechanism is present.")
    }
    if (lookupCount > 10) {
        warnings.add("$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(true, version, mechanisms, redirect, exp, lookupCount, spfLines.size, warnings)
}

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

/** Decode base64 to a byte count without throwing on bad input. Null on invalid base64. */
private fun decodeBase64Lenient(b64: String): Int? {
    // Callers pass trimmed, non-empty values (empty p= is handled before this).
    val clean = b64.replace(Regex("\\s+"), "")
    val padded = clean + "=".repeat((4 - clean.length % 4) % 4)
    return try {
        Base64.getDecoder().decode(padded).size
    } catch (_: IllegalArgumentException) {
        null
    }
}

private fun parseTags(txt: String): Pair<MutableMap<String, String>, MutableList<String>> {
    val tags = mutableMapOf<String, String>()
    val warnings = mutableListOf<String>()
    for (part in txt.split(';')) {
        val term = part.trim().replace(QUOTED_RE, "$1").trim()
        if (term.isEmpty()) continue
        val eq = term.indexOf('=')
        if (eq <= 0) {
            warnings.add("Malformed tag \"$term\" ignored.")
            continue
        }
        tags[term.substring(0, eq).trim().lowercase()] = term.substring(eq + 1).trim()
    }
    return tags to warnings
}

/** Parse a `<selector>._domainkey` TXT record. Pure. */
fun parseDKIM(txt: String): DKIMRecord {
    val (tags, warnings) = parseTags(txt)

    val version = tags["v"]
    if (version != null && version.uppercase() != "DKIM1") {
        warnings.add("Unusual version tag v=$version (expected DKIM1).")
    }
    val keyType = tags["k"] ?: "rsa"
    val p = tags["p"]
    val hashes = (tags["h"] ?: "").split(':').map { it.trim() }.filter { it.isNotEmpty() }
    val services = (tags["s"] ?: "").split(':').map { it.trim() }.filter { it.isNotEmpty() }
    val flags = (tags["t"] ?: "").split(':').map { it.trim() }.filter { it.isNotEmpty() }

    var record = DKIMRecord(
        valid = true, version = version?.uppercase(), keyType = keyType,
        hashes = hashes, services = services, flags = flags, warnings = warnings,
    )

    when {
        p == null -> {
            warnings.add("No p= tag — this record is not a usable DKIM key.")
            return record.copy(valid = false)
        }
        p.isEmpty() -> {
            warnings.add("p= is empty — the key is revoked. Receivers will treat mail signed with this selector as unsigned.")
            return record
        }
    }

    val decoded = decodeBase64Lenient(p)
    if (decoded == null) {
        warnings.add("The p= value is not valid base64 — the key could not be read.")
        return record
    }
    record = record.copy(publicKey = p.replace(Regex("\\s+"), ""))

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

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

private fun policyOf(token: String): DMARCPolicy? =
    DMARCPolicy.entries.firstOrNull { it.token == token }

/** Parse a `_dmarc` TXT record. Pure. */
fun parseDMARC(txt: String): DMARCRecord {
    val (tags, warnings) = parseTags(txt)

    var record = DMARCRecord(valid = true, warnings = warnings)

    val version = tags["v"]
    when {
        version == null -> {
            warnings.add("No v= tag — this is not a DMARC record.")
            return record.copy(valid = false)
        }
        version.uppercase() != "DMARC1" -> {
            warnings.add("Unknown version v=$version (expected DMARC1).")
            return record.copy(valid = false)
        }
    }

    val p = tags["p"]?.lowercase()
    if (p.isNullOrEmpty()) {
        warnings.add("No p= policy tag — DMARC requires it.")
        return record.copy(valid = false)
    }
    val policy = policyOf(p)
    if (policy == null) {
        warnings.add("Invalid policy p=$p (expected none, quarantine, or reject).")
        return record.copy(valid = false)
    }
    record = record.copy(policy = policy)

    tags["sp"]?.lowercase()?.takeIf { it.isNotEmpty() }?.let { sp ->
        val sub = policyOf(sp)
        if (sub != null) {
            record = record.copy(subdomainPolicy = sub)
        } else {
            warnings.add("Invalid sp=$sp ignored (expected none, quarantine, or reject).")
        }
    }

    tags["rua"]?.takeIf { it.isNotEmpty() }?.let { rua ->
        record = record.copy(aggregateUris = rua.split(',').map { it.trim() }.filter { it.isNotEmpty() })
    }
    tags["ruf"]?.takeIf { it.isNotEmpty() }?.let { ruf ->
        record = record.copy(forensicUris = ruf.split(',').map { it.trim() }.filter { it.isNotEmpty() })
    }

    tags["pct"]?.let { pct ->
        val n = pct.toIntOrNull()
        if (n == null || n < 0 || n > 100) {
            warnings.add("Invalid pct=$pct ignored (must be 0-100).")
        } else {
            record = record.copy(percent = n)
        }
    }

    tags["adkim"]?.let { adkim ->
        if (adkim == "r" || adkim == "s") record = record.copy(dkimAlignment = adkim)
        else warnings.add("Invalid adkim=$adkim ignored (expected r or s).")
    }
    tags["aspf"]?.let { aspf ->
        if (aspf == "r" || aspf == "s") record = record.copy(spfAlignment = aspf)
        else warnings.add("Invalid aspf=$aspf ignored (expected r or s).")
    }

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

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

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

/** Query TXT records for a name via Cloudflare's DoH JSON API. Returns unquoted strings. */
private fun queryTxt(name: String): List<String> {
    val request = HttpRequest.newBuilder()
        .uri(URI.create("$DOH_ENDPOINT?name=${java.net.URLEncoder.encode(name, Charsets.UTF_8)}&type=TXT"))
        .header("Accept", "application/dns-json")
        .GET()
        .build()
    val response = HttpClient.newHttpClient()
        .send(request, HttpResponse.BodyHandlers.ofString())
    if (response.statusCode() !in 200..299) {
        throw IllegalStateException("DNS resolver responded with HTTP ${response.statusCode()}.")
    }
    val json = Json.parseToJsonElement(response.body()).jsonObject
    val status = json["Status"]?.jsonPrimitive?.int
        ?: throw IllegalStateException("DNS query failed with unknown status.")
    if (status == 3) return emptyList() // NXDOMAIN — no such domain
    if (status != 0) throw IllegalStateException("DNS query failed with status $status.")
    val answers = json["Answer"]?.jsonArray ?: return emptyList()
    return answers
        .map { it.jsonObject }
        .filter { it["type"]?.jsonPrimitive?.int == 16 } // TXT
        .map { a ->
            // multi-chunk TXT: "part1" "part2"
            a.getValue("data").jsonPrimitive.content
                .replace(QUOTED_RE, "$1")
                .replace("\" \"", "")
        }
}

private fun warnIf(f: Boolean, message: String): String? = if (f) message else null

private fun errorMessage(err: Exception): String =
    err.message?.takeIf { it.isNotBlank() } ?: "DNS lookup failed."

/** Look up and evaluate a domain's SPF record. */
fun checkSPF(domain: String): SPFResult {
    val host = normalizeDomain(domain)
    if (!isDomainLike(host)) return SPFResult(CheckStatus.FAIL, false, message = "Enter a valid domain, e.g. example.com.")
    return try {
        val record = parseSPF(queryTxt(host).joinToString("\n"))
        if (!record.valid) {
            SPFResult(CheckStatus.FAIL, false, message = "No SPF record found for $host. Receivers cannot verify which servers may send mail for it.")
        } else {
            val status = if (record.warnings.isNotEmpty()) CheckStatus.WARN else CheckStatus.PASS
            SPFResult(status, true, record, warnIf(status == CheckStatus.PASS, "SPF record found and looks healthy."))
        }
    } catch (err: Exception) {
        SPFResult(CheckStatus.FAIL, false, message = errorMessage(err))
    }
}

/** Look up and evaluate a domain's DKIM public key for one selector. */
fun checkDKIM(domain: String, selector: String): DKIMResult {
    val host = normalizeDomain(domain)
    if (!isDomainLike(host)) return DKIMResult(CheckStatus.FAIL, false, message = "Enter a valid domain, e.g. example.com.")
    val sel = selector.trim().lowercase()
    if (!isValidSelector(sel)) return DKIMResult(CheckStatus.FAIL, false, message = "Enter a valid selector (letters, digits, dots, hyphens, underscores).")
    return try {
        val record = parseDKIM(queryTxt("$sel._domainkey.$host").joinToString("\n"))
        if (!record.valid) {
            DKIMResult(CheckStatus.FAIL, false, message = "No DKIM record found at $sel._domainkey.$host. Try another selector — only one is checked per lookup.")
        } else {
            val revoked = record.warnings.any { it.contains("revoked") }
            val status = when {
                revoked -> CheckStatus.FAIL
                record.warnings.isNotEmpty() -> CheckStatus.WARN
                else -> CheckStatus.PASS
            }
            DKIMResult(status, true, record)
        }
    } catch (err: Exception) {
        DKIMResult(CheckStatus.FAIL, false, message = errorMessage(err))
    }
}

/** Look up and evaluate a domain's DMARC policy. */
fun checkDMARC(domain: String): DMARCResult {
    val host = normalizeDomain(domain)
    if (!isDomainLike(host)) return DMARCResult(CheckStatus.FAIL, false, message = "Enter a valid domain, e.g. example.com.")
    return try {
        val record = parseDMARC(queryTxt("_dmarc.$host").joinToString("\n"))
        if (!record.valid) {
            DMARCResult(CheckStatus.FAIL, false, message = "No DMARC record found at _dmarc.$host. Receivers have no policy to apply when SPF or DKIM fails.")
        } else {
            val status = if (record.warnings.isNotEmpty()) CheckStatus.WARN else CheckStatus.PASS
            DMARCResult(status, true, record)
        }
    } catch (err: Exception) {
        DMARCResult(CheckStatus.FAIL, false, message = errorMessage(err))
    }
}

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 →