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 →