DKIM / SPF / DMARC Builder & Checker — C# 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 C# implementation — the same logic the interactive tool runs, in a shareable, citable form.
// DKIM / SPF / DMARC — email authentication record parsing and lookup.
// C# 12 / .NET 8 — ported from src/lib/dkim-spf-dmarc.ts (the canonical
// TypeScript implementation). Display source for CosmoDev's polyglot pages.
//
// Two layers, mirroring the TS reference:
// - Pure parsers (ParseSPF / ParseDKIM / ParseDMARC) — deterministic, never
// throw, unit-testable without network.
// - Check functions (CheckSPFAsync / CheckDKIMAsync / CheckDMARCAsync) — one
// DNS-over-HTTPS request to Cloudflare's public resolver, then a pure
// parse. The static HttpClient keeps that to one connection pool.
//
// Specs: SPF RFC 7208, DKIM RFC 6376, DMARC RFC 7489.
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
/// <summary>Overall verdict of one check.</summary>
public enum CheckStatus
{
Pass,
Warn,
Fail,
}
/// <summary>SPF qualifier prefix: pass, fail, softfail, neutral.</summary>
public enum SpfQualifier
{
Pass,
Fail,
SoftFail,
Neutral,
}
/// <summary>One SPF mechanism term, e.g. "-include:_spf.example.com".</summary>
public sealed record SpfMechanism(SpfQualifier Qualifier, string Kind, string? Value);
/// <summary>A parsed (or missing) SPF record plus its warnings.</summary>
public sealed class SpfRecord
{
public bool Valid { get; set; }
public string? Version { get; set; }
public List<SpfMechanism> Mechanisms { get; set; } = [];
public string? Redirect { get; set; }
public string? Exp { get; set; }
/// <summary>Mechanisms that cost a DNS query under RFC 7208 §4.6.4 (limit 10).</summary>
public int LookupCount { get; set; }
/// <summary>Number of v=spf1 records found (more than one is a hard error for receivers).</summary>
public int RecordCount { get; set; }
public List<string> Warnings { get; set; } = [];
}
/// <summary>A parsed DKIM selector record plus its warnings.</summary>
public sealed class DkimRecord
{
public bool Valid { get; set; }
public string? Version { get; set; }
public string KeyType { get; set; } = "rsa";
/// <summary>Public key, base64, whitespace-stripped.</summary>
public string? PublicKey { get; set; }
/// <summary>Approximate modulus size in bits (RSA only; derived from DER byte length).</summary>
public int? KeyBits { get; set; }
public List<string> Hashes { get; set; } = [];
public List<string> Services { get; set; } = [];
public List<string> Flags { get; set; } = [];
public List<string> Warnings { get; set; } = [];
}
public enum DmarcPolicy
{
None,
Quarantine,
Reject,
}
/// <summary>A parsed DMARC policy record plus its warnings.</summary>
public sealed class DmarcRecord
{
public bool Valid { get; set; }
public DmarcPolicy? Policy { get; set; }
public DmarcPolicy? SubdomainPolicy { get; set; }
public List<string> AggregateUris { get; set; } = [];
public List<string> ForensicUris { get; set; } = [];
public int? Percent { get; set; }
/// <summary>'r' (relaxed) or 's' (strict) DKIM identifier alignment.</summary>
public char? DkimAlignment { get; set; }
/// <summary>'r' (relaxed) or 's' (strict) SPF identifier alignment.</summary>
public char? SpfAlignment { get; set; }
public List<string> Warnings { get; set; } = [];
}
/// <summary>Outcome of one live DNS check: status + record + message.</summary>
public sealed record SpfResult(CheckStatus Status, bool Found, SpfRecord? Record = null, string? Message = null);
public sealed record DkimResult(CheckStatus Status, bool Found, DkimRecord? Record = null, string? Message = null);
public sealed record DmarcResult(CheckStatus Status, bool Found, DmarcRecord? Record = null, string? Message = null);
public static class DkimSpfDmarc
{
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
/// <summary>Strip a leading scheme, userinfo, path, query, port, and trailing dot from user input.</summary>
public static string NormalizeDomain(string raw)
{
var s = raw.Trim();
s = Regex.Replace(s, @"^[a-z][a-z0-9+.-]*://", "", RegexOptions.IgnoreCase); // scheme://
s = Regex.Replace(s, @"^mailto:", "", RegexOptions.IgnoreCase); // mailto:user@domain
if (s.Contains('@')) s = s[(s.LastIndexOf('@') + 1)..]; // keep host of user@host
s = s.Split('/')[0]; // drop path
s = s.Split('?')[0]; // drop query
s = s.Split(':')[0]; // drop port
s = Regex.Replace(s, @"\.+$", ""); // trailing dot(s)
return s.ToLowerInvariant();
}
private static readonly Regex DomainRegex =
new("^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z]{2,}$", RegexOptions.Compiled);
/// <summary>True when the string looks like a plausible multi-label domain (example.com).</summary>
public static bool IsDomainLike(string domain) =>
DomainRegex.IsMatch(domain) && domain.Length <= 253;
private static readonly Regex SelectorRegex = new("^[a-z0-9][a-z0-9._-]*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>True when the selector is a safe single DNS label chain (no spaces, no traversal).</summary>
public static bool IsValidSelector(string selector)
{
var s = selector.Trim();
return s.Length > 0 && s.Length <= 100 && SelectorRegex.IsMatch(s) && !s.Contains("..");
}
// ---------------------------------------------------------------------------
// SPF — RFC 7208
// ---------------------------------------------------------------------------
private static readonly HashSet<string> SpfLookupKinds =
["include", "a", "mx", "exists", "ptr"];
private static readonly Regex SpfLineRegex = new(@"^v=spf1(\s|$)", RegexOptions.IgnoreCase);
private static readonly Regex ModifierRegex = new(@"^([a-z][a-z0-9-]*)=(.*)$", RegexOptions.IgnoreCase);
private static readonly Regex MechanismRegex = new(@"^([+\-~?])?([a-z0-9]+)(?::(.*))?$", RegexOptions.IgnoreCase);
private static readonly Regex QuotedRegex = new(@"^""(.*)""$");
private static readonly HashSet<string> SpfKinds =
["all", "include", "a", "mx", "ip4", "ip6", "exists", "ptr"];
/// <summary>Parse one or more (newline-joined) TXT record strings for SPF. Pure.</summary>
public static SpfRecord ParseSPF(string txt)
{
var lines = txt
.Split('\n')
.Select(l => QuotedRegex.Replace(l.Trim(), "$1"))
.Where(l => l.Length > 0)
.ToList();
var spfLines = lines.Where(l => SpfLineRegex.IsMatch(l)).ToList();
var warnings = new List<string>();
if (spfLines.Count == 0)
{
return new SpfRecord
{
Valid = false,
Warnings = ["No v=spf1 record found in the supplied text."],
};
}
if (spfLines.Count > 1)
{
warnings.Add(
$"{spfLines.Count} SPF records found — RFC 7208 allows exactly one. Receivers treat this as a permanent error and ignore SPF for the whole domain.");
}
var terms = spfLines[0].Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
var version = terms[0];
var mechanisms = new List<SpfMechanism>();
string? redirect = null;
string? exp = null;
foreach (var term in terms.Skip(1))
{
// Modifiers use '=': redirect= and exp=
var modifier = ModifierRegex.Match(term);
if (modifier.Success)
{
var name = modifier.Groups[1].Value.ToLowerInvariant();
if (name == "redirect") redirect = modifier.Groups[2].Value;
else if (name == "exp") exp = modifier.Groups[2].Value;
else warnings.Add($"Unknown modifier \"{term}\" ignored.");
continue;
}
var m = MechanismRegex.Match(term);
if (!m.Success)
{
warnings.Add($"Unrecognized term \"{term}\" ignored.");
continue;
}
var qualifier = m.Groups[1].Success ? m.Groups[1].Value[0] : '+';
var kind = m.Groups[2].Value.ToLowerInvariant();
var value = m.Groups[3].Success ? m.Groups[3].Value : null;
if (!SpfKinds.Contains(kind))
{
warnings.Add($"Unknown mechanism \"{term}\" ignored.");
continue;
}
if (value == null && (kind == "include" || kind == "exists"))
{
warnings.Add($"Mechanism \"{term}\" is missing its required value.");
continue;
}
mechanisms.Add(new SpfMechanism(ToQualifier(qualifier), kind, value));
}
var lookupCount = mechanisms.Count(mech => SpfLookupKinds.Contains(mech.Kind)) + (redirect != null ? 1 : 0);
var all = mechanisms.FirstOrDefault(mech => mech.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 != null && 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 != null && all.Qualifier == SpfQualifier.Neutral)
{
warnings.Add("\"?all\" (Neutral) lets unmatched senders through with no protection. Prefer \"~all\" or \"-all\".");
}
if (mechanisms.Any(mech => mech.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 new SpfRecord
{
Valid = true,
Version = version,
Mechanisms = mechanisms,
Redirect = redirect,
Exp = exp,
LookupCount = lookupCount,
RecordCount = spfLines.Count,
Warnings = warnings,
};
static SpfQualifier ToQualifier(char c) => c switch
{
'-' => SpfQualifier.Fail,
'~' => SpfQualifier.SoftFail,
'?' => SpfQualifier.Neutral,
_ => SpfQualifier.Pass,
};
}
// ---------------------------------------------------------------------------
// DKIM — RFC 6376
// ---------------------------------------------------------------------------
/// <summary>Decode base64 to raw DER bytes without throwing on bad input.</summary>
private static byte[]? DecodeBase64Lenient(string b64)
{
// Callers pass trimmed, non-empty values (empty p= is handled before this).
var clean = Regex.Replace(b64, @"\s+", "");
var padded = clean + new string('=', (4 - clean.Length % 4) % 4);
try
{
return Convert.FromBase64String(padded);
}
catch (FormatException)
{
return null;
}
}
/// <summary>Parse a <selector>._domainkey TXT record. Pure.</summary>
public static DkimRecord ParseDKIM(string txt)
{
var warnings = new List<string>();
var tags = new Dictionary<string, string>();
foreach (var part in txt.Split(';'))
{
var term = QuotedRegex.Replace(part.Trim(), "$1").Trim();
if (term.Length == 0) continue;
var eq = term.IndexOf('=');
if (eq <= 0)
{
warnings.Add($"Malformed tag \"{term}\" ignored.");
continue;
}
tags[term[..eq].Trim().ToLowerInvariant()] = term[(eq + 1)..].Trim();
}
var record = new DkimRecord { Valid = true, Warnings = warnings };
if (tags.TryGetValue("v", out var version))
{
if (version.ToUpperInvariant() != "DKIM1")
{
warnings.Add($"Unusual version tag v={version} (expected DKIM1).");
}
record.Version = version.ToUpperInvariant();
}
record.KeyType = tags.GetValueOrDefault("k", "rsa");
var p = tags.TryGetValue("p", out var pValue) ? pValue : null;
record.Hashes = SplitList(tags.GetValueOrDefault("h"));
record.Services = SplitList(tags.GetValueOrDefault("s"));
record.Flags = SplitList(tags.GetValueOrDefault("t"));
if (p == null)
{
record.Valid = false;
warnings.Add("No p= tag — this record is not a usable DKIM key.");
return record;
}
if (p == "")
{
warnings.Add("p= is empty — the key is revoked. Receivers will treat mail signed with this selector as unsigned.");
return record;
}
var decoded = DecodeBase64Lenient(p);
if (decoded == null)
{
warnings.Add("The p= value is not valid base64 — the key could not be read.");
return record;
}
record.PublicKey = Regex.Replace(p, @"\s+", "");
if (record.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.
var bits = Math.Max(0, decoded.Length - 24) * 8;
record.KeyBits = bits;
if (bits < 1024)
{
warnings.Add($"Weak RSA key (~{bits} bits). Keys under 1024 bits are considered breakable; RFC 8301 discourages short keys.");
}
else if (bits < 2048)
{
warnings.Add($"RSA key of ~{bits} bits works today but is below the recommended 2048 bits (RFC 8301).");
}
}
if (record.Flags.Contains("y"))
{
warnings.Add("t=y — the key is in test mode: receivers must treat signatures as if unsigned.");
}
if (record.Flags.Contains("s"))
{
warnings.Add("t=s — strict domain matching: the key cannot be used for subdomain signatures (informational).");
}
return record;
static List<string> SplitList(string? joined) =>
(joined ?? "").Split(':').Select(s => s.Trim()).Where(s => s.Length > 0).ToList();
}
// ---------------------------------------------------------------------------
// DMARC — RFC 7489
// ---------------------------------------------------------------------------
private static readonly Dictionary<string, DmarcPolicy> DmarcPolicies = new()
{
["none"] = DmarcPolicy.None,
["quarantine"] = DmarcPolicy.Quarantine,
["reject"] = DmarcPolicy.Reject,
};
/// <summary>Parse a _dmarc TXT record. Pure.</summary>
public static DmarcRecord ParseDMARC(string txt)
{
var warnings = new List<string>();
var tags = new Dictionary<string, string>();
foreach (var part in txt.Split(';'))
{
var term = QuotedRegex.Replace(part.Trim(), "$1").Trim();
if (term.Length == 0) continue;
var eq = term.IndexOf('=');
if (eq <= 0)
{
warnings.Add($"Malformed tag \"{term}\" ignored.");
continue;
}
tags[term[..eq].Trim().ToLowerInvariant()] = term[(eq + 1)..].Trim();
}
var record = new DmarcRecord { Valid = true, Warnings = warnings };
if (!tags.TryGetValue("v", out var version))
{
record.Valid = false;
warnings.Add("No v= tag — this is not a DMARC record.");
return record;
}
if (version.ToUpperInvariant() != "DMARC1")
{
record.Valid = false;
warnings.Add($"Unknown version v={version} (expected DMARC1).");
return record;
}
if (!tags.TryGetValue("p", out var p) || string.IsNullOrEmpty(p))
{
record.Valid = false;
warnings.Add("No p= policy tag — DMARC requires it.");
return record;
}
if (!DmarcPolicies.TryGetValue(p.ToLowerInvariant(), out var policy))
{
record.Valid = false;
warnings.Add($"Invalid policy p={p} (expected none, quarantine, or reject).");
return record;
}
record.Policy = policy;
if (tags.TryGetValue("sp", out var sp) && !string.IsNullOrEmpty(sp))
{
if (DmarcPolicies.TryGetValue(sp.ToLowerInvariant(), out var subPolicy))
{
record.SubdomainPolicy = subPolicy;
}
else
{
warnings.Add($"Invalid sp={sp} ignored (expected none, quarantine, or reject).");
}
}
if (tags.TryGetValue("rua", out var rua))
{
record.AggregateUris = rua.Split(',').Select(u => u.Trim()).Where(u => u.Length > 0).ToList();
}
if (tags.TryGetValue("ruf", out var ruf))
{
record.ForensicUris = ruf.Split(',').Select(u => u.Trim()).Where(u => u.Length > 0).ToList();
}
if (tags.TryGetValue("pct", out var pct) && int.TryParse(pct, out var n))
{
if (n is < 0 or > 100)
{
warnings.Add($"Invalid pct={pct} ignored (must be 0-100).");
}
else
{
record.Percent = n;
}
}
else if (tags.ContainsKey("pct"))
{
warnings.Add($"Invalid pct={tags["pct"]} ignored (must be 0-100).");
}
if (tags.TryGetValue("adkim", out var adkim) && adkim.Length > 0)
{
if (adkim is "r" or "s") record.DkimAlignment = adkim[0];
else warnings.Add($"Invalid adkim={adkim} ignored (expected r or s).");
}
if (tags.TryGetValue("aspf", out var aspf) && aspf.Length > 0)
{
if (aspf is "r" or "s") record.SpfAlignment = aspf[0];
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.Count == 0)
{
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.Count > 0)
{
warnings.Add(
"ruf= (forensic reports) is supported by few receivers and may leak message content to the report address (informational).");
}
if (record.Percent is < 100 && record.Policy != DmarcPolicy.None)
{
warnings.Add(
$"pct={record.Percent} applies the policy to only {record.Percent}% of mail — the other {100 - record.Percent}% is unaffected.");
}
return record;
}
// ---------------------------------------------------------------------------
// DNS-over-HTTPS lookup
// ---------------------------------------------------------------------------
private const string DohEndpoint = "https://cloudflare-dns.com/dns-query";
private static readonly HttpClient Http = new();
/// <summary>Query TXT records for a name via Cloudflare's DoH JSON API. Returns unquoted strings.</summary>
private static async Task<List<string>> QueryTxtAsync(string name)
{
var url = $"{DohEndpoint}?name={Uri.EscapeDataString(name)}&type=TXT";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Accept.ParseAdd("application/dns-json");
using var response = await Http.SendAsync(request).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"DNS resolver responded with HTTP {(int)response.StatusCode}.");
}
using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync().ConfigureAwait(false)).ConfigureAwait(false);
var root = json.RootElement;
if (root.TryGetProperty("Status", out var status))
{
if (status.GetInt32() == 3) return []; // NXDOMAIN — no such domain
if (status.GetInt32() != 0)
{
throw new InvalidOperationException($"DNS query failed with status {status.GetInt32()}.");
}
}
var results = new List<string>();
if (root.TryGetProperty("Answer", out var answer) && answer.ValueKind == JsonValueKind.Array)
{
foreach (var entry in answer.EnumerateArray())
{
if (!entry.TryGetProperty("type", out var type) || type.GetInt32() != 16) continue;
var data = entry.GetProperty("data").GetString() ?? "";
// multi-chunk TXT: "part1" "part2"
results.Add(QuotedRegex.Replace(data, "$1").Replace("\" \"", ""));
}
}
return results;
}
private static string? WarnIf(bool condition, string message) => condition ? message : null;
/// <summary>Look up and evaluate a domain's SPF record.</summary>
public static async Task<SpfResult> CheckSPFAsync(string domain)
{
var host = NormalizeDomain(domain);
if (!IsDomainLike(host))
{
return new SpfResult(CheckStatus.Fail, false, Message: "Enter a valid domain, e.g. example.com.");
}
try
{
var txts = await QueryTxtAsync(host).ConfigureAwait(false);
var record = ParseSPF(string.Join('\n', txts));
if (!record.Valid)
{
return new SpfResult(CheckStatus.Fail, false,
Message: $"No SPF record found for {host}. Receivers cannot verify which servers may send mail for it.");
}
var status = record.Warnings.Count > 0 ? CheckStatus.Warn : CheckStatus.Pass;
return new SpfResult(status, true, record, WarnIf(status == CheckStatus.Pass, "SPF record found and looks healthy."));
}
catch (Exception err) when (err is InvalidOperationException or HttpRequestException or JsonException)
{
return new SpfResult(CheckStatus.Fail, false, Message: err.Message);
}
}
/// <summary>Look up and evaluate a domain's DKIM public key for one selector.</summary>
public static async Task<DkimResult> CheckDKIMAsync(string domain, string selector)
{
var host = NormalizeDomain(domain);
if (!IsDomainLike(host))
{
return new DkimResult(CheckStatus.Fail, false, Message: "Enter a valid domain, e.g. example.com.");
}
var sel = selector.Trim().ToLowerInvariant();
if (!IsValidSelector(sel))
{
return new DkimResult(CheckStatus.Fail, false,
Message: "Enter a valid selector (letters, digits, dots, hyphens, underscores).");
}
try
{
var txts = await QueryTxtAsync($"{sel}._domainkey.{host}").ConfigureAwait(false);
var record = ParseDKIM(string.Join('\n', txts));
if (!record.Valid)
{
return new DkimResult(CheckStatus.Fail, false,
Message: $"No DKIM record found at {sel}._domainkey.{host}. Try another selector — only one is checked per lookup.");
}
var revoked = record.Warnings.Any(w => w.Contains("revoked"));
var status = revoked ? CheckStatus.Fail : record.Warnings.Count > 0 ? CheckStatus.Warn : CheckStatus.Pass;
return new DkimResult(status, true, record);
}
catch (Exception err) when (err is InvalidOperationException or HttpRequestException or JsonException)
{
return new DkimResult(CheckStatus.Fail, false, Message: err.Message);
}
}
/// <summary>Look up and evaluate a domain's DMARC policy.</summary>
public static async Task<DmarcResult> CheckDMARCAsync(string domain)
{
var host = NormalizeDomain(domain);
if (!IsDomainLike(host))
{
return new DmarcResult(CheckStatus.Fail, false, Message: "Enter a valid domain, e.g. example.com.");
}
try
{
var txts = await QueryTxtAsync($"_dmarc.{host}").ConfigureAwait(false);
var record = ParseDMARC(string.Join('\n', txts));
if (!record.Valid)
{
return new DmarcResult(CheckStatus.Fail, false,
Message: $"No DMARC record found at _dmarc.{host}. Receivers have no policy to apply when SPF or DKIM fails.");
}
var status = record.Warnings.Count > 0 ? CheckStatus.Warn : CheckStatus.Pass;
return new DmarcResult(status, true, record);
}
catch (Exception err) when (err is InvalidOperationException or HttpRequestException or JsonException)
{
return new DmarcResult(CheckStatus.Fail, false, Message: err.Message);
}
}
}
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 →