Since K2 2.8.5, the Kladde has been sending mails: Whoever books an appointment gets a confirmation with a link. For that you built the port IMailSender and an implementation that sends from a mailbox through Microsoft Graph. Whether such a mail arrives, lands in the junk folder or is rejected isn’t decided by your code. Three TXT records in DNS (lesson 3.0.3) decide, and the reputation of the server that delivers the mail. In this lesson you follow a mail on its way, read its header like a log and check SPF, DKIM and DMARC with small programs. At the end you know why the Kladde never sends from its container itself.

The way of a mail

A mail travels in three stages:

  1. Submission: The program or mail client hands the mail to its own provider, with a sign-in. The Kladde does this through Graph (sendMail), a classic client over SMTP on port 587.
  2. Delivery: The provider’s server asks DNS for the MX record of the recipient’s domain, that is, for its mail server, connects to it on port 25 and hands over the mail by SMTP (Simple Mail Transfer Protocol). Between servers there is no sign-in. Every server on the internet may offer a mail to every other.
  3. Storing and retrieval: The receiving server checks the mail, files it in the mailbox, and the recipient reads it with Outlook, in the browser or over IMAP.
Port What for
25 server to server, no sign-in; TLS is offered (STARTTLS) but not enforced
587 submission with sign-in, TLS through STARTTLS
465 submission with sign-in, TLS from the start
993 IMAP over TLS, for retrieval

A forward is a fourth case you should remember right away: If a mailbox forwards every mail to another address, its server sends the mail off a second time, from its own address. The next server then sees a sender that the original domain never named.

SMTP in conversation

SMTP is a text protocol: The client sends commands, the server answers with a three-digit number and a text. This program starts a tiny receiving server and talks to it, the way a provider’s server does on delivery. Everything runs on your computer; no mail leaves it.

C#
// smtp.cs: a tiny receiving mail server and a sender in one program, to show the SMTP dialog,
// the envelope (MAIL FROM, RCPT TO) and the header (From, To) as two different things.
using System.Net;
using System.Net.Sockets;
using System.Text;

var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var server = ReceiveAsync(listener);

using (var client = new TcpClient())
{
    await client.ConnectAsync(IPAddress.Loopback, port);
    using var stream = client.GetStream();
    var reader = new StreamReader(stream, Encoding.ASCII);
    var writer = new StreamWriter(stream, Encoding.ASCII) { NewLine = "\r\n", AutoFlush = true };

    await ExpectAsync(reader);
    await SendAsync(writer, reader, "EHLO outbound.kladde.example");
    await SendAsync(writer, reader, "MAIL FROM:<bounces@kladde.example>");
    await SendAsync(writer, reader, "RCPT TO:<ada.berger@inbox.example>");
    await SendAsync(writer, reader, "DATA");
    foreach (var line in new[]
    {
        "From: Kladde <appointments@kladde.example>",
        "To: Ada Berger <ada.berger@inbox.example>",
        "Subject: Your appointment on Tuesday",
        "",
        "Hello Ms. Berger, please confirm your appointment.",
    })
    {
        await writer.WriteLineAsync(line);
        Console.WriteLine($"C: {line}".TrimEnd());
    }

    await SendAsync(writer, reader, ".");
    await SendAsync(writer, reader, "QUIT");
}

Console.WriteLine();
Console.WriteLine("What the receiver stored:");
Console.WriteLine(await server);

static async Task SendAsync(StreamWriter writer, StreamReader reader, string line)
{
    await writer.WriteLineAsync(line);
    Console.WriteLine($"C: {line}");
    await ExpectAsync(reader);
}

static async Task ExpectAsync(StreamReader reader)
{
    string? line;
    do
    {
        line = await reader.ReadLineAsync();
        Console.WriteLine($"S: {line}");
    }
    while (line is [_, _, _, '-', ..]); // "250-" means: more lines follow
}

// The receiving side: answers each command with a code and stores the message with what it learned from the envelope.
static async Task<string> ReceiveAsync(TcpListener listener)
{
    using var connection = await listener.AcceptTcpClientAsync();
    using var stream = connection.GetStream();
    var reader = new StreamReader(stream, Encoding.ASCII);
    var writer = new StreamWriter(stream, Encoding.ASCII) { NewLine = "\r\n", AutoFlush = true };
    string helo = "", mailFrom = "", rcptTo = "";
    var body = new StringBuilder();

    await writer.WriteLineAsync("220 mx.inbox.example ESMTP");
    while (await reader.ReadLineAsync() is { } command)
    {
        if (command.StartsWith("EHLO ", StringComparison.OrdinalIgnoreCase))
        {
            helo = command[5..];
            await writer.WriteLineAsync("250-mx.inbox.example");
            await writer.WriteLineAsync("250 SIZE 10485760");
        }
        else if (command.StartsWith("MAIL FROM:", StringComparison.OrdinalIgnoreCase))
        {
            mailFrom = command[10..];
            await writer.WriteLineAsync("250 2.1.0 Sender OK");
        }
        else if (command.StartsWith("RCPT TO:", StringComparison.OrdinalIgnoreCase))
        {
            rcptTo = command[8..];
            await writer.WriteLineAsync("250 2.1.5 Recipient OK");
        }
        else if (command == "DATA")
        {
            await writer.WriteLineAsync("354 Start mail input; end with <CRLF>.<CRLF>");
            while (await reader.ReadLineAsync() is { } line && line != ".")
            {
                body.AppendLine(line);
            }

            await writer.WriteLineAsync("250 2.0.0 OK queued as 7F3A");
        }
        else if (command == "QUIT")
        {
            await writer.WriteLineAsync("221 2.0.0 Bye");
            break;
        }
    }

    // A receiving server adds Return-Path (from MAIL FROM) and a Received line on top; it never changes the header below.
    return $"Return-Path: {mailFrom}\nReceived: from {helo} by mx.inbox.example for {rcptTo}\n{body}";
}
Bash
dotnet run smtp.cs
S: 220 mx.inbox.example ESMTP
C: EHLO outbound.kladde.example
S: 250-mx.inbox.example
S: 250 SIZE 10485760
C: MAIL FROM:<bounces@kladde.example>
S: 250 2.1.0 Sender OK
C: RCPT TO:<ada.berger@inbox.example>
S: 250 2.1.5 Recipient OK
C: DATA
S: 354 Start mail input; end with <CRLF>.<CRLF>
C: From: Kladde <appointments@kladde.example>
C: To: Ada Berger <ada.berger@inbox.example>
C: Subject: Your appointment on Tuesday
C:
C: Hello Ms. Berger, please confirm your appointment.
C: .
S: 250 2.0.0 OK queued as 7F3A
C: QUIT
S: 221 2.0.0 Bye

What the receiver stored:
Return-Path: <bounces@kladde.example>
Received: from outbound.kladde.example by mx.inbox.example for <ada.berger@inbox.example>
From: Kladde <appointments@kladde.example>
To: Ada Berger <ada.berger@inbox.example>
Subject: Your appointment on Tuesday

Hello Ms. Berger, please confirm your appointment.

The text sticks to plain ASCII because real mails encode accented letters and umlauts first (MIME); the example leaves that out. The server’s answers follow a simple rule: 2xx means done, 3xx go on (354: send the text), 4xx not for now (the sender tries again later), 5xx never. A 5xx answer turns into a bounce message that reaches the sender.

The most important thing about this conversation: A mail has two senders.

Envelope (SMTP) Header (in the text of the mail)
Sender MAIL FROM, stored as Return-Path; technical name 5321.MailFrom From:, what the reader sees; technical name 5322.From
Recipient RCPT TO, where it is delivered To: and Cc:, for display only; whoever is in Bcc is only in the envelope
What for bounces and error messages go to MAIL FROM replies go to From: (or Reply-To:)

Nothing in SMTP requires the two to match. Anyone can write whatever they like in From:, appointments@kladde.example included. SPF, DKIM and DMARC exist precisely against that.

Reading the header of a mail

Every server a mail passes through writes a Received line at the top of the header. So the way is read from bottom to top. The header of a Kladde confirmation could look like this at the recipient’s; names and addresses are invented:

Received: from mx.inbox.example (198.51.100.7) by mailbox.inbox.example
 with LMTP; Tue, 4 Mar 2025 09:14:05 +0000
Authentication-Results: mx.inbox.example;
 spf=pass (sender IP is 203.0.113.25) smtp.mailfrom=kladde.example;
 dkim=pass (signature was verified) header.d=kladde.example;
 dmarc=pass action=none header.from=kladde.example
Received: from out-12.outbound.example (203.0.113.25) by mx.inbox.example
 (198.51.100.7) with ESMTPS (TLS1_3); Tue, 4 Mar 2025 09:14:04 +0000
Received: from mailbox.outbound.example (2001:db8:12::7) by out-12.outbound.example
 with HTTPS; Tue, 4 Mar 2025 09:14:02 +0000
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=kladde.example;
 s=selector1; h=From:Date:Subject:Message-ID:Content-Type:MIME-Version;
 bh=3q2+7w1Jx0e0ZfX7c0yYkq1mR9n0TqkZ7Wq3m0bC1hY=; b=Vb7tK2...
Return-Path: <appointments@kladde.example>
From: Kladde <appointments@kladde.example>
To: Ada Berger <ada.berger@inbox.example>
Subject: Your appointment on Tuesday
Date: Tue, 4 Mar 2025 09:14:01 +0000
Message-ID: <a1b2c3d4@kladde.example>
MIME-Version: 1.0
Content-Type: text/plain; charset="utf-8"

Hello Ms. Berger, ...

Indented lines belong to the field above (a long field is folded). The fields that matter:

  • Received: who got the mail from whom, with address and time. For mail from Microsoft 365, the servers are actually named something like ….prod.outlook.com.
  • Return-Path: the sender from the envelope (MAIL FROM).
  • DKIM-Signature: the sender’s signature, with domain d= and selector s=; more on that shortly.
  • Authentication-Results: the result of the checks, written by the receiving server.

For Authentication-Results, the same applies as for X-Forwarded-For in lesson 3.0.5: Believe only the topmost entry, written by the server of your own provider. Further down, a sender can write in whatever they like.

This program reads a saved header and summarizes it. Because a header is text from strangers, it uses only linear regular expressions (RegexOptions.NonBacktracking) that don’t run long on any input:

C#
// headers.cs: reads the header of a mail saved as text and shows the way it took and what the receiving server checked.
// dotnet run headers.cs -- <file>
using System.Text.RegularExpressions;

var fields = new List<(string Name, string Value)>();
foreach (var line in File.ReadLines(args[0]))
{
    if (line.Length == 0)
    {
        break; // the header ends at the first empty line, the body follows
    }

    if (line[0] is ' ' or '\t' && fields.Count > 0)
    {
        fields[^1] = (fields[^1].Name, $"{fields[^1].Value} {line.Trim()}"); // a folded line continues the field above
    }
    else if (line.IndexOf(':') is > 0 and var colon)
    {
        fields.Add((line[..colon].Trim(), line[(colon + 1)..].Trim()));
    }
}

Console.WriteLine("Path of the mail, the first step is at the very bottom of the header:");
var step = 0;
foreach (var received in All("Received").Reverse())
{
    var hop = Find(received, @"(from (\S+)( \([^)]*\))? )?by (\S+)");
    var source = hop.Groups[2].Success ? hop.Groups[2].Value + hop.Groups[3].Value : "?";
    var time = received.Contains(';') ? received[(received.LastIndexOf(';') + 1)..].Trim() : "";
    Console.WriteLine($"  {++step}. {source} -> {hop.Groups[4].Value}, {time}");
}

// Only the topmost Authentication-Results comes from your own provider; lower ones may be the sender's own words.
var results = All("Authentication-Results").FirstOrDefault() ?? "";
var from = AfterAt(Find(All("From").FirstOrDefault() ?? "", @"@([^>\s]+)").Value);
var mailFrom = AfterAt(Find(results, @"smtp\.mailfrom=([^\s;]+)").Groups[1].Value);
var dkimDomain = AfterAt(Find(results, @"header\.[di]=([^\s;]+)").Groups[1].Value); // some providers write header.i=@domain
var selector = Find(All("DKIM-Signature").FirstOrDefault() ?? "", @"(^|;)\s*s=([^\s;]+)").Groups[2].Value;

Console.WriteLine($"Sender in the header (From):          {from}");
Console.WriteLine($"Sender in the envelope (Return-Path): {AfterAt(Find(All("Return-Path").FirstOrDefault() ?? "", @"@([^>\s]+)").Value)}");
Console.WriteLine($"SPF   {Result("spf"),-5} for {mailFrom,-18} {Aligned(mailFrom)}");
Console.WriteLine($"DKIM  {Result("dkim"),-5} for {dkimDomain,-18} {Aligned(dkimDomain)}, selector {selector}");
Console.WriteLine($"DMARC {Result("dmarc"),-5} {Find(results, @"dmarc=\w+([^;]*)").Groups[1].Value.Trim()}");

IEnumerable<string> All(string name) => fields.Where(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase)).Select(f => f.Value);

string Result(string method) => Find(results, $@"\b{method}=(\w+)").Groups[1].Value;

// DMARC "relaxed": the same organizational domain, here simply the last two labels (the real rule uses the public suffix list).
string Aligned(string domain) => domain.Length == 0 ? "" : Organization(domain) == Organization(from) ? "matches From" : "doesn't match From";

static string Organization(string domain) => string.Join('.', domain.Split('.').TakeLast(2));

static string AfterAt(string value) => value[(value.LastIndexOf('@') + 1)..];

// A header is text from strangers: linear expressions only, so no input can make them run for long.
static Match Find(string text, string pattern) => Regex.Match(text, pattern, RegexOptions.NonBacktracking | RegexOptions.IgnoreCase);

Save the header above as appointment.txt next to the program:

Bash
dotnet run headers.cs -- appointment.txt
Path of the mail, the first step is at the very bottom of the header:
  1. mailbox.outbound.example (2001:db8:12::7) -> out-12.outbound.example, Tue, 4 Mar 2025 09:14:02 +0000
  2. out-12.outbound.example (203.0.113.25) -> mx.inbox.example, Tue, 4 Mar 2025 09:14:04 +0000
  3. mx.inbox.example (198.51.100.7) -> mailbox.inbox.example, Tue, 4 Mar 2025 09:14:05 +0000
Sender in the header (From):          kladde.example
Sender in the envelope (Return-Path): kladde.example
SPF   pass  for kladde.example     matches From
DKIM  pass  for kladde.example     matches From, selector selector1
DMARC pass  action=none header.from=kladde.example

What “matches From” means is explained in the section on DMARC. First, the two checks it builds on.

SPF: Who may send for the domain?

SPF (Sender Policy Framework) is a TXT record on the domain that lists the servers allowed to deliver mail for it. For a domain whose mail goes only through Microsoft 365, Microsoft recommends:

kladde.example.  3600  TXT  "v=spf1 include:spf.protection.outlook.com -all"

Read from left to right:

  • v=spf1 makes the TXT record an SPF record.
  • Mechanisms name allowed senders: ip4:203.0.113.0/24 and ip6: for addresses, include: for another service’s list, a and mx for the addresses of your own A and MX records.
  • all at the end matches everything that hasn’t matched so far. The character before it determines the result: -all means fail, ~all softfail (suspicious, but accept), ?all neutral.

The receiving server checks the address the connection comes from against the SPF record of the domain from MAIL FROM, that is, from the envelope. SPF doesn’t look at the From: in the header. Results are pass, fail, softfail, neutral, none (no record), temperror (DNS currently unreachable) and permerror (the record is broken).

Three rules that are violated most often in practice:

  • Only one SPF record per name. Whoever creates a second TXT record with v=spf1 for a second service gets permerror for all mails. Further services go into the one record as include:.
  • At most ten lookups. Every include, a, mx, exists and redirect costs a DNS query, including those in included records. More than ten result in permerror.
  • A domain without mail says so: v=spf1 -all, like example.com in lesson 3.0.3. Otherwise anyone can forge mail in its name.

SPF has two weaknesses. It checks only the envelope, which the reader never sees. And it breaks on every forward, because then a server delivers that isn’t in the SPF record of the original domain.

DKIM: A signature that travels along

DKIM (DomainKeys Identified Mail) signs the mail itself. The sender’s server computes a hash over the body (bh=) and signs it together with selected header fields (h=) using a private key (b=). The matching public key is published by the domain in DNS, under a name made of selector and domain: <s>._domainkey.<d>. The selector allows several keys side by side, for example one per service, or an old and a new one during a changeover.

With Microsoft 365, Microsoft generates the key pair and keeps the private key. You create two CNAME records, selector1._domainkey and selector2._domainkey, that point to Microsoft’s records. The exact targets are shown by the Defender portal or Get-DkimSigningConfig in Exchange Online PowerShell. Never rebuild them by hand; the format has changed before. One of the two selectors signs, the other waits for the next key rollover.

This program shows the idea in small, with the real rules for relaxed but without folded lines:

C#
// dkim.cs: DKIM in small. The sender signs a hash of the body and some header fields with its private key; the receiver
// checks them with the public key it reads from DNS. Simplified (no folded lines, only rsa-sha256), with the real "relaxed" rules.
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;

using var privateKey = RSA.Create(2 * 1024); // bits
var publicKey = Convert.ToBase64String(privateKey.ExportSubjectPublicKeyInfo());
var dns = new Dictionary<string, string> { ["selector1._domainkey.kladde.example"] = $"v=DKIM1; k=rsa; p={publicKey}" };
Console.WriteLine("TXT selector1._domainkey.kladde.example");
Console.WriteLine($"  v=DKIM1; k=rsa; p={publicKey[..32]}... ({publicKey.Length} characters)");

(string Name, string Value)[] header =
[
    ("From", "Kladde <appointments@kladde.example>"),
    ("To", "Ada Berger <ada.berger@inbox.example>"),
    ("Subject", "Your appointment on Tuesday"),
];
var body = "Hello Ms. Berger,\r\n\r\nYour appointment: Tuesday, 10:00.\r\n";

// The sender (Microsoft 365 for the Kladde) signs with the private key; the selector says where the public key is.
var unsigned = $"v=1; a=rsa-sha256; c=relaxed/relaxed; d=kladde.example; s=selector1; h=from:to:subject; bh={BodyHash(body)}; b=";
var signature = unsigned + Convert.ToBase64String(
    privateKey.SignData(Encoding.ASCII.GetBytes(SignedText(header, unsigned)), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
Console.WriteLine($"DKIM-Signature: {unsigned}... ({signature.Length - unsigned.Length} characters)");

Console.WriteLine("At the receiver:");
Check("unchanged", header, body);
Check("Trailing spaces", header, body.Replace("Berger,", "Berger,   "));
Check("Time in the text changed", header, body.Replace("10:00", "11:00"));
Check("Subject changed", [header[0], header[1], ("Subject", "Your appointment on Wednesday")], body);

void Check(string change, (string Name, string Value)[] received, string receivedBody) =>
    Console.WriteLine($"  {change,-26} {Verify(received, receivedBody, signature, dns)}");

// The receiver: compares the body hash, then checks the signature over the named header fields with the key from DNS.
static string Verify((string Name, string Value)[] header, string body, string signature, Dictionary<string, string> dns)
{
    var tags = Tags(signature);
    if (BodyHash(body) != tags["bh"])
    {
        return "dkim=fail (body hash did not verify)";
    }

    using var publicKey = RSA.Create();
    publicKey.ImportSubjectPublicKeyInfo(Convert.FromBase64String(Tags(dns[$"{tags["s"]}._domainkey.{tags["d"]}"])["p"]), out _);
    var signed = Encoding.ASCII.GetBytes(SignedText(header, signature[..(signature.LastIndexOf("; b=") + 4)]));
    var valid = publicKey.VerifyData(signed, Convert.FromBase64String(tags["b"]), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
    return valid ? $"dkim=pass header.d={tags["d"]}" : "dkim=fail (signature did not verify)";
}

// "relaxed" for the body: spaces at the end of a line go, runs of spaces become one, empty lines at the end go.
static string BodyHash(string body)
{
    var lines = body.Split("\r\n").Select(line => Regex.Replace(line, "[ \t]+", " ").TrimEnd()).ToList();
    while (lines.Count > 0 && lines[^1].Length == 0)
    {
        lines.RemoveAt(lines.Count - 1);
    }

    var canonical = lines.Count == 0 ? "" : string.Join("\r\n", lines) + "\r\n";
    return Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(canonical)));
}

// What the signature covers: the fields named in h= ("relaxed": lower-case name, single spaces), then the DKIM-Signature field itself with an empty b=.
static string SignedText((string Name, string Value)[] header, string signatureWithoutB)
{
    var text = new StringBuilder();
    foreach (var name in Tags(signatureWithoutB)["h"].Split(':'))
    {
        var value = header.First(field => field.Name.Equals(name, StringComparison.OrdinalIgnoreCase)).Value;
        text.Append($"{name}:{Regex.Replace(value, "[ \t]+", " ").Trim()}\r\n");
    }

    return text.Append($"dkim-signature:{signatureWithoutB}").ToString();
}

static Dictionary<string, string> Tags(string record) => record.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
    .ToDictionary(tag => tag[..tag.IndexOf('=')], tag => tag[(tag.IndexOf('=') + 1)..]);
Bash
dotnet run dkim.cs
TXT selector1._domainkey.kladde.example
  v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A... (392 characters)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=kladde.example; s=selector1; h=from:to:subject; bh=nstiGZ5jYtrCiw33AYHBtK5mIMgIZMuxC46zt398ry4=; b=... (344 characters)
At the receiver:
  unchanged                  dkim=pass header.d=kladde.example
  Trailing spaces            dkim=pass header.d=kladde.example
  Time in the text changed   dkim=fail (body hash did not verify)
  Subject changed            dkim=fail (signature did not verify)

The key is a new one on every run; the hash bh= stays the same because the body stays the same. What you see:

  • relaxed forgives small things: spaces at the end of a line, several spaces in a row, empty lines at the end. Servers like to make such changes along the way.
  • One changed word in the body changes the hash. The receiver notices before it even checks the signature.
  • One changed header field that is listed in h= invalidates the signature.

Because the signature travels inside the mail, DKIM survives a forward, as long as nobody changes the body. A mailing list that appends a footer or puts [List] in front of the subject breaks it. For such cases there is ARC (Authenticated Received Chain): The forwarding server records, in signed fields of its own, what it checked on arrival, and a receiver that trusts it evaluates that.

DMARC: Does it match the sender the reader sees?

SPF checks the envelope, DKIM a domain d= that the sender chooses freely. Neither checks the From: that the reader sees. A forger can send a mail with From: appointments@kladde.example from their own domain, with SPF pass for fraud.example and DKIM pass for fraud.example. DMARC (Domain-based Message Authentication, Reporting and Conformance) closes this gap.

A DMARC record is a TXT record under _dmarc on the domain:

_dmarc.kladde.example.  3600  TXT  "v=DMARC1; p=quarantine; sp=reject; rua=mailto:dmarc-reports@kladde.example"

A mail passes DMARC if at least one of the two checks passes and matches its From: (alignment):

  • SPF is aligned if the domain from MAIL FROM matches the domain from From:.
  • DKIM is aligned if the domain d= of a valid signature matches the domain from From:.

“Matches” means relaxed by default (aspf=r, adkim=r): the same organizational domain, so news.kladde.example matches kladde.example. With strict (s), the name must be exactly the same.

If a mail doesn’t pass, the domain tells the receiver with p= what to do: none (nothing, just report), quarantine (into the junk folder) or reject (turn it away). sp= applies to subdomains without a record of their own, including invented ones like invoice.kladde.example. With rua=, the domain asks receivers for daily aggregate reports: XML files with the sending addresses, counts and results, from which you learn who all sends in your name. Individual reports (ruf=) are sent by only a few providers, and Microsoft 365 sends none.

This program decides like a receiving server and checks itself against cases, the way nsg.cs does in lesson 3.0.6:

C#
// dmarc.cs: decides like a receiving server whether a mail passes DMARC for the domain in its From field,
// and what that domain's policy asks for when it does not. Each case says what should come out.
var dns = new Dictionary<string, string>
{
    ["_dmarc.kladde.example"] = "v=DMARC1; p=quarantine; sp=reject; rua=mailto:dmarc-reports@kladde.example",
};

(string Name, string From, string MailFrom, string Spf, string DkimDomain, string Dkim, string Expected)[] cases =
[
    ("Confirmation from the mailbox", "kladde.example", "kladde.example", "pass", "kladde.example", "pass", "deliver"),
    ("forwarded, text unchanged", "kladde.example", "kladde.example", "fail", "kladde.example", "pass", "deliver"),
    ("forwarded, footer added", "kladde.example", "kladde.example", "fail", "kladde.example", "fail", "quarantine"),
    ("Newsletter service, not set up", "kladde.example", "mailer.example", "pass", "mailer.example", "pass", "quarantine"),
    ("Newsletter service on news.kladde.example", "news.kladde.example", "bounce.news.kladde.example", "pass", "news.kladde.example", "pass", "deliver"),
    ("Forgery, envelope uses its own domain", "kladde.example", "fraud.example", "pass", "", "none", "quarantine"),
    ("Forgery, invented subdomain", "invoice.kladde.example", "invoice.kladde.example", "none", "", "none", "reject"),
];

var failed = 0;
Console.WriteLine($"{"Case",-41} {"SPF",-12} {"DKIM",-12} {"DMARC",-6} Outcome");
foreach (var mail in cases)
{
    // The policy: the From domain's own record, else the organizational domain's, whose sp= then applies.
    var own = dns.TryGetValue($"_dmarc.{mail.From}", out var record);
    var policy = Tags(own ? record! : dns.GetValueOrDefault($"_dmarc.{Organization(mail.From)}", "v=DMARC1; p=none"));
    var applies = own ? policy["p"] : policy.GetValueOrDefault("sp", policy["p"]);

    // Passed means: SPF or DKIM passed AND its domain matches the From domain ("aligned").
    var spfAligned = mail.Spf == "pass" && Aligned(mail.MailFrom, mail.From, policy.GetValueOrDefault("aspf", "r"));
    var dkimAligned = mail.Dkim == "pass" && Aligned(mail.DkimDomain, mail.From, policy.GetValueOrDefault("adkim", "r"));
    var passed = spfAligned || dkimAligned;
    var outcome = passed || applies == "none" ? "deliver" : applies;

    var ok = outcome == mail.Expected;
    failed += ok ? 0 : 1;
    Console.WriteLine($"{mail.Name,-41} {Show(mail.Spf, spfAligned),-12} {Show(mail.Dkim, dkimAligned),-12} {(passed ? "pass" : "fail"),-6} " +
        $"{outcome}{(ok ? "" : $"  EXPECTED {mail.Expected}")}");
}

Console.WriteLine(failed == 0 ? "All cases as expected." : $"Cases that differ: {failed}.");
return failed == 0 ? 0 : 1;

// r (relaxed): the same organizational domain; s (strict): exactly the same name.
static bool Aligned(string domain, string from, string mode) =>
    mode == "s" ? domain == from : Organization(domain) == Organization(from);

// Simplified: the last two labels. The real rule uses the public suffix list, so that example.co.uk works too.
static string Organization(string domain) => string.Join('.', domain.Split('.').TakeLast(2));

static string Show(string result, bool aligned) => result == "pass" ? (aligned ? "pass, match" : "pass, other") : result;

static Dictionary<string, string> Tags(string record) => record.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
    .ToDictionary(tag => tag[..tag.IndexOf('=')], tag => tag[(tag.IndexOf('=') + 1)..]);
Bash
dotnet run dmarc.cs
Case                                      SPF          DKIM         DMARC  Outcome
Confirmation from the mailbox             pass, match  pass, match  pass   deliver
forwarded, text unchanged                 fail         pass, match  pass   deliver
forwarded, footer added                   fail         fail         fail   quarantine
Newsletter service, not set up            pass, other  pass, other  fail   quarantine
Newsletter service on news.kladde.example pass, match  pass, match  pass   deliver
Forgery, envelope uses its own domain     pass, other  none         fail   quarantine
Forgery, invented subdomain               none         none         fail   reject
All cases as expected.

Read the rows one by one:

  • Forwarded, text unchanged: SPF fails, DKIM carries the mail. That’s why DKIM is a must for every domain that sends mail.
  • Newsletter service, not set up: SPF and DKIM pass, but for the service’s domain. For DMARC that doesn’t count. This is the most common reason why your own mails land in the junk folder after DMARC is switched on.
  • Forgery, envelope uses its own domain: SPF passes for fraud.example, and that is exactly why SPF alone isn’t enough.
  • Invented subdomain: It has no record of its own, so the main domain’s sp=reject applies.

DMARC is introduced step by step, as Microsoft recommends too: first p=none and read the reports until every sender of your own passes aligned; then p=quarantine; in the end p=reject. Whoever starts with reject right away only learns from complaints which forgotten service still sends in the domain’s name.

Why the Kladde sends only through a mailbox

In K2 2.8.5 you decided that the Kladde sends its mails through Graph from an Exchange Online mailbox. Now you know the reasons why it doesn’t simply deliver by SMTP itself from the container:

  • Port 25 is closed. Azure blocks outbound connections on port 25 for most kinds of subscriptions; for services like Container Apps, sending directly over port 25 isn’t supported at all. Microsoft recommends sending through a service with sign-in, that is, port 587 or an API like Graph.
  • The reputation is missing. Receiving servers check whether the sending address has a matching PTR record (lesson 3.0.3) and whether it has sent decent mail so far. An address from a data center that sends for the first time today has neither.
  • SPF and DKIM are taken care of. Microsoft 365 sends from addresses that include:spf.protection.outlook.com names, and signs with the selectors your CNAMEs point to. All you publish is DMARC.
  • Replies and bounces arrive. Whoever replies to the confirmation lands in a real mailbox that someone reads.

Bulk mail is something else. A newsletter to a thousand contacts doesn’t belong in an Exchange Online mailbox: Microsoft explicitly doesn’t support that, and a blocked mailbox would hit the confirmations too. Three rules apply to bulk mail:

  • A service of its own, built for it. Microsoft has announced the end of Azure Communication Services’ mail service and points to providers in the Microsoft Marketplace; which one it will be is a decision by price and terms.
  • A subdomain of its own, such as news.kladde.example, with the SPF and DKIM the service prescribes. Complaints about the newsletter then harm the subdomain’s reputation, not that of the confirmations from kladde.example.
  • Unsubscribing with one click through the header fields List-Unsubscribe and List-Unsubscribe-Post (RFC 8058). Large mailbox providers such as Outlook.com and Gmail demand SPF, DKIM and DMARC from domains that send a lot; Outlook.com otherwise rejects with 550 5.7.515.

Exercise 1: Reading the header of a real mail

  1. Create headers.cs and appointment.txt in your practice folder, outside any repository, and run the program.
  2. Save the header of a real mail from your mailbox as mail.txt, preferably that of a newsletter you subscribe to. In Outlook it’s “View message details” or “Properties” with the field “Internet headers”, in Gmail “Show original”. Copy only the header up to the first empty line.
  3. Run headers.cs with mail.txt and answer: How many servers did the mail pass through, and where did it start? Do SPF, DKIM and DMARC pass? Do the checked domains match the From:? Which selector signed?
  4. Also run headers.cs with newsletter.txt below and explain why DMARC fails and how the Kladde would fix it.
Received: from mx.inbox.example (198.51.100.7) by mailbox.inbox.example
 with LMTP; Thu, 6 Mar 2025 16:02:11 +0000
Authentication-Results: mx.inbox.example;
 spf=pass (sender IP is 192.0.2.44) smtp.mailfrom=mailer.example;
 dkim=pass (signature was verified) header.d=mailer.example;
 dmarc=fail action=quarantine header.from=kladde.example
Received: from out3.mailer.example (192.0.2.44) by mx.inbox.example
 (198.51.100.7) with ESMTPS (TLS1_3); Thu, 6 Mar 2025 16:02:10 +0000
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=mailer.example; s=m1;
 h=From:To:Subject:Date:List-Unsubscribe; bh=Qm9yaW5nIGJ1dCByZWFsLWxvb2tpbmcgYm9keSBoYXNo; b=k9F2...
Return-Path: <bounce-8812@mailer.example>
From: Kladde <news@kladde.example>
To: ada.berger@inbox.example
Subject: New in the Kladde
Date: Thu, 6 Mar 2025 16:01:58 +0000

...

A mail header contains your address and the addresses of servers. It belongs neither in a repository nor in a chat or a ticket.

How to check: headers.cs shows at least two steps for mail.txt and one line each for SPF, DKIM and DMARC, and you have a sentence for each line. For newsletter.txt, you name the cause and a way that lets DMARC pass.

Solution 1

Your mail is a different one; for a newsletter from a large retailer it typically looks like this: four to eight steps, the first ones in the network of the sending service, the last ones at your provider. SPF passes for a subdomain like bounce.news.<retailer>, DKIM for news.<retailer> or the main domain, both “match From”, DMARC pass. If compauth=pass is there, Microsoft 365 has additionally given an overall rating of its own. You can look up the selector in DNS in exercise 2.

newsletter.txt:

Path of the mail, the first step is at the very bottom of the header:
  1. out3.mailer.example (192.0.2.44) -> mx.inbox.example, Thu, 6 Mar 2025 16:02:10 +0000
  2. mx.inbox.example (198.51.100.7) -> mailbox.inbox.example, Thu, 6 Mar 2025 16:02:11 +0000
Sender in the header (From):          kladde.example
Sender in the envelope (Return-Path): mailer.example
SPF   pass  for mailer.example     doesn't match From
DKIM  pass  for mailer.example     doesn't match From, selector m1
DMARC fail  action=quarantine header.from=kladde.example

The newsletter service sends with its own domain in the envelope and signs with its own domain. Both are valid on their own but don’t match kladde.example, so DMARC fails, and p=quarantine sends the mail to the junk folder. This is fixed at the service, not at the receiver: The service signs with the Kladde’s domain (it names CNAME or TXT records for a selector of its own for that), and best of all it sends from news.kladde.example with an envelope of its own below it. Then DKIM passes aligned, and usually SPF too.

Exercise 2: Querying a domain’s records

This script shows a domain’s SPF and DMARC and, if you name a selector, the DKIM key with its length:

PowerShell
# mailauth.ps1: shows a domain's SPF and DMARC records and, given a selector, its DKIM key (Windows: Resolve-DnsName).
param(
    [Parameter(Mandatory)] [string] $Domain,
    [string] $Selector
)

function Get-TxtRecord([string] $Name) {
    Resolve-DnsName -Name $Name -Type TXT -DnsOnly -ErrorAction SilentlyContinue |
        Where-Object Type -EQ 'TXT' |
        ForEach-Object { $_.Strings -join '' }
}

$spf = @(Get-TxtRecord $Domain | Where-Object { $_ -like 'v=spf1 *' })
$dmarc = @(Get-TxtRecord "_dmarc.$Domain" | Where-Object { $_ -like 'v=DMARC1*' })
"SPF    $($spf -join ' | ')"
if ($spf.Count -gt 1) { '       Error: more than one SPF record, receivers treat that as permerror.' }
"DMARC  $($dmarc -join ' | ')"

if ($Selector) {
    $dkim = @(Get-TxtRecord "$Selector._domainkey.$Domain" | Where-Object { $_ -like '*p=*' })
    foreach ($record in $dkim) {
        $key = ($record -split ';' | Where-Object { $_.Trim() -like 'p=*' }).Trim().Substring(2)
        $rsa = [System.Security.Cryptography.RSA]::Create()
        $read = 0
        $rsa.ImportSubjectPublicKeyInfo([Convert]::FromBase64String($key), [ref] $read)
        "DKIM   $Selector`: $($record.Substring(0, [Math]::Min(40, $record.Length)))... (RSA, $($rsa.KeySize) bit)"
    }
    if ($dkim.Count -eq 0) { "DKIM   $Selector`: no key" }
}
  1. Run it for outlook.com and example.com and explain every part of the records.
  2. Query the DKIM key of microsoft.com with the selector selector1 and with selector2. What do you conclude?
  3. Query the domain of the mail from exercise 1, with the selector that signed there.
  4. For each of the domains, answer: What happens to a forged mail whose From: names this domain?

Resolve-DnsName exists only on Windows. In the codespace, you query with dig +short TXT outlook.com, dig +short TXT _dmarc.outlook.com and dig +short TXT selector2._domainkey.microsoft.com.

How to check: For each record you have a sentence that says what it’s for, and for each domain an answer to question 4.

Solution 2

When this lesson was written, the answers were as follows; records change, yours may differ:

PowerShell
.\mailauth.ps1 -Domain outlook.com
.\mailauth.ps1 -Domain example.com
.\mailauth.ps1 -Domain microsoft.com -Selector selector2
.\mailauth.ps1 -Domain microsoft.com -Selector selector1
SPF    v=spf1 include:spf2.outlook.com -all
DMARC  v=DMARC1; p=none; sp=quarantine; pct=100; rua=mailto:rua@dmarc.microsoft; ruf=mailto:ruf@dmarc.microsoft; fo=1
SPF    v=spf1 -all
DMARC  v=DMARC1;p=reject;sp=reject;adkim=s;aspf=s
SPF
DMARC  v=DMARC1; p=reject; pct=100; rua=mailto:itex-rua@microsoft.com; ruf=mailto:itex-ruf@microsoft.com; fo=1
DKIM   selector2: v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQ... (RSA, 1024 bit)
SPF
DMARC  v=DMARC1; p=reject; pct=100; rua=mailto:itex-rua@microsoft.com; ruf=mailto:itex-ruf@microsoft.com; fo=1
DKIM   selector1: no key
  • outlook.com: SPF names a list of address ranges with include:spf2.outlook.com and ends in -all; whoever isn’t in it gets fail. DMARC for the main domain is p=none, observe only, for subdomains quarantine. fo=1 asks for an individual report as soon as one of the two checks fails. A forgery with From: …@outlook.com is therefore only reported, not rejected; the receivers then decide with their own filters.
  • example.com: No server may send, and DMARC demands reject with strict alignment. A forgery is rejected.
  • microsoft.com: The SPF record is missing from this output, although it exists: The domain has dozens of TXT records, and when this lesson was written, the answer didn’t arrive completely on Windows. If something is missing for a large domain, ask with dig in the codespace. DMARC is reject, a forgery is rejected.
  • The selectors: Only selector2 has a key, selector1 doesn’t exist. That is the Microsoft 365 pattern, one selector is active, the other is next in line at the next rollover. The name points by CNAME to a record under onmicrosoft.com, the older format; newly set-up domains point to ….dkim.mail.microsoft. The key has 1,024 bits, the Microsoft 365 default; 2,048 bits are possible and the better choice.

Exercise 3: Designing DMARC for the Kladde

  1. Create dmarc.cs and run it.
  2. Change p=quarantine to p=reject. Which rows now report EXPECTED, and which of them are mails that should arrive? Restore the old value.
  3. Extend the record with aspf=s; adkim=s. Say beforehand which column changes in which row, and then check. Take the addition out again.
  4. Add two cases with expectations: A support service sends as support@kladde.example with the envelope helpdesk.example, but signs with d=kladde.example because the Kladde created its DKIM records. And a colleague sends from their own mail server as kladde.example, which is neither in the SPF record nor signs.
  5. Write the records kladde.example needs for mail as a table with a “Why” column: SPF and DKIM for Microsoft 365, DMARC with the steps of the rollout, SPF and DKIM for a newsletter service on news.kladde.example. Write values that a portal prescribes as placeholders.

How to check: dmarc.cs ends with your new cases and the original record with “All cases as expected.” and exit code 0, and your table has a name, type, value and a reason for every record.

Solution 3

On 2: Three rows change from quarantine to reject:

forwarded, footer added                   fail         fail         fail   reject  EXPECTED quarantine
Newsletter service, not set up            pass, other  pass, other  fail   reject  EXPECTED quarantine
Forgery, envelope uses its own domain     pass, other  none         fail   reject  EXPECTED quarantine
Cases that differ: 3.

The forgery is supposed to be rejected, that’s the point. The other two are real mails: a forwarded confirmation to which a server appended a footer, and your own newsletter. With reject, both would be lost without the Kladde ever learning of it. That is exactly why reject comes last: first read the reports, align your own services, then tighten. Against the footer, only ARC at the receiver helps, not a record of the Kladde’s.

On 3: Only one cell changes, SPF for the newsletter service on news.kladde.example:

Newsletter service on news.kladde.example pass, other  pass, match  pass   deliver

bounce.news.kladde.example is not exactly news.kladde.example, so with aspf=s SPF is no longer aligned. DKIM signs with exactly the name from From: and carries the mail on. Here too it shows: DKIM is the more important half.

On 4: The two new cases at the end of the list:

C#
    ("Support service with the Kladde's DKIM", "kladde.example", "helpdesk.example", "pass", "kladde.example", "pass", "deliver"),
    ("colleague's own server", "kladde.example", "kladde.example", "fail", "", "none", "quarantine"),
Support service with the Kladde's DKIM    pass, other  pass, match  pass   deliver
colleague's own server                    fail         none         fail   quarantine
All cases as expected.

The colleague’s server isn’t a forger, but for the receiver it can’t be told apart from one. It either belongs in the SPF record and signs, or the colleague sends through Microsoft 365 like everyone else.

On 5:

Name Type Value Why
kladde.example TXT v=spf1 include:spf.protection.outlook.com -all only Microsoft 365 delivers mail for the main domain
selector1._domainkey.kladde.example CNAME target from the Defender portal DKIM key from Microsoft 365, active
selector2._domainkey.kladde.example CNAME target from the Defender portal second key for the rollover
_dmarc.kladde.example TXT v=DMARC1; p=none; rua=mailto:dmarc-reports@kladde.example step 1: just observe, read the reports
_dmarc.kladde.example TXT v=DMARC1; p=quarantine; sp=reject; rua=mailto:dmarc-reports@kladde.example step 2, once all your own senders pass aligned
_dmarc.kladde.example TXT v=DMARC1; p=reject; sp=reject; rua=mailto:dmarc-reports@kladde.example step 3, the goal
news.kladde.example TXT v=spf1 include:<the service's value> -all only the newsletter service sends for the subdomain
<the service's selector>._domainkey.news.kladde.example CNAME or TXT the service’s value the service signs with the subdomain

The three DMARC rows are never in DNS at the same time; they are the steps one after another, and there is always only one DMARC record. The mailbox for rua receives many reports as XML. You don’t have to read them by hand; in sum, the reports show whether a sender is still missing.

The Kladde after this lesson

The Kladde doesn’t change. You now know why its IMailSender sends through a mailbox and not from the container, and what a domain needs for its confirmations to arrive. When the Kladde gets a domain of its own in K4, the table from exercise 3 is the template.

What you can do now

  • You know the way of a mail via MX and SMTP and tell the envelope (MAIL FROM, RCPT TO) from the header (From:, To:).
  • You read a mail’s header from bottom to top and believe only the topmost Authentication-Results.
  • You read and write SPF records and know the rules: one record, at most ten lookups, -all.
  • You explain DKIM with signature, selector and public key and know why it survives forwards and not footers.
  • You explain DMARC with alignment, p=, sp= and reports, and you introduce it step by step.
  • You justify why applications send through a mailbox or a service, and why bulk mail gets a subdomain of its own.

Read more