ZetCode

C# Mailpit

last modified October 18, 2026

In this article we show how to test emails sent from C# applications with Mailpit and its REST API.

Mailpit

Mailpit is a free, open source email testing tool. It runs an SMTP server that accepts all messages but never delivers them; instead, it stores them and displays them in a web interface. Applications under development can send real emails without the risk of reaching real customers.

Besides the web interface, Mailpit has a REST API. With it, we can list, search, read, and delete the caught messages from code, which makes it possible to verify emails in automated tests. Mailpit can also check the links and the HTML compatibility of a message, and it can simulate SMTP errors.

In the send mail article, we use Mailpit as a test server for sending emails. Here we focus on the other side: checking what the application actually sent.

Running Mailpit

The easiest way to run Mailpit is in a Docker container.

$ docker run -d --name mailpit -p 8025:8025 -p 1025:1025 axllent/mailpit \
    --smtp-auth-accept-any --smtp-auth-allow-insecure --enable-chaos

Mailpit listens for SMTP connections on port 1025; the web interface and the REST API are available on port 8025.

By default, the messages are stored in a temporary database and are lost when Mailpit stops; with the --database option, we can specify a file for persistent storage. The examples were tested with Mailpit 1.31.

Mailpit web interface
Figure: Mailpit web interface

Sending a test email

First, we send a message that we inspect in the next examples. We use the MailKit library.

$ dotnet new console -n MailpitDemo
$ cd MailpitDemo
$ dotnet add package MailKit

We create a console project and add MailKit. The rest of the examples use only HttpClient and System.Text.Json from the standard library.

Program.cs
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;

var message = new MimeMessage();

message.From.Add(new MailboxAddress("Shop", "noreply@shop.example.com"));
message.To.Add(new MailboxAddress("Jane Doe", "jane.doe@example.com"));
message.Subject = "Your order #1024";

var builder = new BodyBuilder
{
    TextBody = "Thank you for your order.\nTrack it at https://example.com/orders/1024",
    HtmlBody = """
        <p>Thank you for your <b>order</b>.</p>
        <p><a href="https://example.com/orders/1024">Track your order</a></p>
        """
};

await builder.Attachments.AddAsync("chart.png");
message.Body = builder.ToMessageBody();

using var client = new SmtpClient();

await client.ConnectAsync("localhost", 1025, SecureSocketOptions.None);
await client.SendAsync(message);
await client.DisconnectAsync(true);

Console.WriteLine("Email sent");

The program sends an order confirmation with a plain text body, an HTML body, and a PNG image as an attachment. MailKit is covered in more detail in the send mail article.

$ dotnet run
Email sent

Application information

The /api/v1/info endpoint returns information about the running Mailpit instance.

Program.cs
using System.Net.Http.Json;

using var client = new HttpClient { BaseAddress = new Uri("http://localhost:8025/") };

var info = await client.GetFromJsonAsync<AppInfo>("api/v1/info");

Console.WriteLine($"Mailpit {info!.Version}");
Console.WriteLine($"Messages: {info.Messages}, unread: {info.Unread}");
Console.WriteLine($"Accepted by SMTP: {info.RuntimeStats.SmtpAccepted}");

record AppInfo(string Version, int Messages, int Unread, RuntimeStats RuntimeStats);
record RuntimeStats(long Uptime, int SmtpAccepted, int SmtpRejected);

We print the version, the number of stored messages, and the number of messages accepted by the SMTP server.

using var client = new HttpClient { BaseAddress = new Uri("http://localhost:8025/") };

We set the base address of the client, so that we can use relative URLs in the requests.

var info = await client.GetFromJsonAsync<AppInfo>("api/v1/info");

The GetFromJsonAsync extension method sends a GET request and deserializes the JSON response. It uses the web defaults of System.Text.Json, which match property names case-insensitively; the SMTPAccepted field of the JSON is mapped to the SmtpAccepted property.

record AppInfo(string Version, int Messages, int Unread, RuntimeStats RuntimeStats);

We define records only for the fields we need; the other fields of the response are ignored.

$ dotnet run
Mailpit v1.31.6
Messages: 1, unread: 1
Accepted by SMTP: 1

Listing messages

The /api/v1/messages endpoint returns a list of message summaries, newest first.

Program.cs
using System.Net.Http.Json;

using var client = new HttpClient { BaseAddress = new Uri("http://localhost:8025/") };

var res = await client.GetFromJsonAsync<MessagesResponse>("api/v1/messages?limit=10");

Console.WriteLine($"Total: {res!.Total}, unread: {res.Unread}");

foreach (var m in res.Messages)
{
    string to = string.Join(", ", m.To.Select(a => a.Address));
    Console.WriteLine($"{m.Created:yyyy-MM-dd HH:mm:ss} {m.From.Address} -> {to}");
    Console.WriteLine($"  {m.Subject} ({m.Size} B, attachments: {m.Attachments})");
}

record MessagesResponse(int Total, int Unread, int Start, List<MessageSummary> Messages);
record MessageSummary(string ID, Mailbox From, List<Mailbox> To, string Subject,
    DateTime Created, int Size, int Attachments, bool Read);
record Mailbox(string Name, string Address);

The program prints the date, the sender, the recipients, and the subject of each message.

var res = await client.GetFromJsonAsync<MessagesResponse>("api/v1/messages?limit=10");

The list is paged; the start and limit query parameters select the page. The default limit is 50.

record Mailbox(string Name, string Address);

An email address consists of an optional display name and the address itself. We cannot name the record Address, because a member of a type cannot have the same name as the type.

$ dotnet run
Total: 1, unread: 1
2026-10-18 07:59:55 noreply@shop.example.com -> jane.doe@example.com
  Your order #1024 (53865 B, attachments: 1)

The Created time is the time when Mailpit received the message; it is in UTC.

Message details

The /api/v1/message/{ID} endpoint returns the whole message, including the bodies and the list of attachments.

Program.cs
using System.Net.Http.Json;

using var client = new HttpClient { BaseAddress = new Uri("http://localhost:8025/") };

var msg = await client.GetFromJsonAsync<Message>("api/v1/message/latest");

Console.WriteLine($"Subject: {msg!.Subject}");
Console.WriteLine($"From: {msg.From.Name} <{msg.From.Address}>");
Console.WriteLine($"To: {string.Join(", ", msg.To.Select(a => a.Address))}");
Console.WriteLine($"Date: {msg.Date:yyyy-MM-dd HH:mm:ss zzz}");

Console.WriteLine("--- text ---");
Console.WriteLine(msg.Text);
Console.WriteLine("--- html ---");
Console.WriteLine(msg.HTML);

foreach (var att in msg.Attachments)
{
    Console.WriteLine($"Attachment: {att.FileName} ({att.ContentType}, {att.Size} B, part {att.PartID})");
}

record Message(string ID, Mailbox From, List<Mailbox> To, string Subject,
    DateTimeOffset Date, string Text, string HTML, List<Attachment> Attachments);
record Mailbox(string Name, string Address);
record Attachment(string PartID, string FileName, string ContentType, int Size);

We print the details of the newest message.

var msg = await client.GetFromJsonAsync<Message>("api/v1/message/latest");

Instead of a message ID, we can use the special value latest. Note that reading a message through this endpoint marks it as read, just like opening it in the web interface.

$ dotnet run
Subject: Your order #1024
From: Shop <noreply@shop.example.com>
To: jane.doe@example.com
Date: 2026-10-18 09:59:55 +02:00
--- text ---
Thank you for your order.
Track it at https://example.com/orders/1024
--- html ---
<p>Thank you for your <b>order</b>.</p>
<p><a href="https://example.com/orders/1024">Track your order</a></p>
Attachment: chart.png (image/png, 38514 B, part 2)

Headers and raw source

The /headers endpoint returns all message headers; the /raw endpoint returns the complete source of the message.

Program.cs
using System.Net.Http.Json;

using var client = new HttpClient { BaseAddress = new Uri("http://localhost:8025/") };

var headers = await client.GetFromJsonAsync<Dictionary<string, string[]>>(
    "api/v1/message/latest/headers");

foreach (var name in new[] { "Subject", "Content-Type", "Mime-Version", "Return-Path" })
{
    Console.WriteLine($"{name}: {string.Join("; ", headers![name])}");
}

Console.WriteLine();

string raw = await client.GetStringAsync("api/v1/message/latest/raw");

foreach (var line in raw.Split("\r\n").Where(l => l.StartsWith("Content-Type")))
{
    Console.WriteLine(line);
}

We print some headers and then the content types of all MIME parts found in the raw source.

var headers = await client.GetFromJsonAsync<Dictionary<string, string[]>>(
    "api/v1/message/latest/headers");

The headers are returned as a JSON object; each header has an array of values, because a header may occur several times in a message.

$ dotnet run
Subject: Your order #1024
Content-Type: multipart/mixed; boundary="=-3PeQNGQh9fqcb5NUpyNvJA=="
Mime-Version: 1.0
Return-Path: <noreply@shop.example.com>

Content-Type: multipart/mixed; boundary="=-3PeQNGQh9fqcb5NUpyNvJA=="
Content-Type: multipart/alternative; boundary="=-GZKw0HlVKSAFuoDrlRQasw=="
Content-Type: text/plain; charset=utf-8
Content-Type: text/html; charset=utf-8
Content-Type: image/png; name=chart.png

The output shows the structure of the message: a multipart/mixed part contains a multipart/alternative part with the text and HTML bodies, followed by the attachment.

Downloading attachments

Attachments are downloaded from the /part/{PartID} endpoint.

Program.cs
using System.Net.Http.Json;
using System.Security.Cryptography;

using var client = new HttpClient { BaseAddress = new Uri("http://localhost:8025/") };

var msg = await client.GetFromJsonAsync<Message>("api/v1/message/latest");

Directory.CreateDirectory("downloads");

foreach (var att in msg!.Attachments)
{
    byte[] data = await client.GetByteArrayAsync($"api/v1/message/{msg.ID}/part/{att.PartID}");
    string hash = Convert.ToHexStringLower(SHA256.HashData(data));

    Console.WriteLine($"{att.FileName}: {data.Length} bytes");
    Console.WriteLine($"SHA-256: {hash}");
    Console.WriteLine($"Matches Mailpit checksum: {hash == att.Checksums.SHA256}");

    await File.WriteAllBytesAsync(Path.Combine("downloads", att.FileName), data);
}

record Message(string ID, List<Attachment> Attachments);
record Attachment(string PartID, string FileName, string ContentType, int Size,
    Checksums Checksums);
record Checksums(string MD5, string SHA1, string SHA256);

We download all attachments of the newest message and verify their content.

byte[] data = await client.GetByteArrayAsync($"api/v1/message/{msg.ID}/part/{att.PartID}");
string hash = Convert.ToHexStringLower(SHA256.HashData(data));

We download the attachment and compute its SHA-256 hash.

Console.WriteLine($"Matches Mailpit checksum: {hash == att.Checksums.SHA256}");

Mailpit computes MD5, SHA-1, and SHA-256 checksums of each attachment. In a test, we can compare them with the checksum of the original file without downloading the attachment.

$ dotnet run
chart.png: 38514 bytes
SHA-256: 95588433ec0044aa69904c8f81e97b33dfabc6bd518abb8be149859f2115d8ca
Matches Mailpit checksum: True

The downloaded image is identical to the sent file. Text attachments may differ, though: MimeKit converts the line endings of text/* attachments to CRLF, as required by the email standards, so a file with Unix line endings arrives a few bytes larger.

Searching messages

The /api/v1/search endpoint finds messages with a query language similar to the one used by Gmail. First, we send several messages.

Program.cs
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;

(string To, string Subject, string Body)[] mails =
[
    ("jane.doe@example.com", "Welcome to the shop", "Your account is ready."),
    ("roger.roe@example.com", "Password reset", "Reset your password: https://shop.example.com/reset/a1b2"),
    ("jane.doe+orders@example.com", "Order #1025 shipped", "Your order is on its way."),
    ("lucy.smith@example.com", "Weekly newsletter", "New products this week."),
    ("jane.doe@example.com", "Password reset", "Reset your password: https://shop.example.com/reset/c3d4")
];

using var client = new SmtpClient();
await client.ConnectAsync("localhost", 1025, SecureSocketOptions.None);

foreach (var (to, subject, body) in mails)
{
    var message = new MimeMessage();
    message.From.Add(new MailboxAddress("Shop", "noreply@shop.example.com"));
    message.To.Add(MailboxAddress.Parse(to));
    message.Subject = subject;
    message.Body = new TextPart("plain") { Text = body };

    await client.SendAsync(message);
}

await client.DisconnectAsync(true);

Console.WriteLine($"Sent {mails.Length} emails");

The program sends five messages to different recipients over one SMTP connection.

Program.cs
using System.Net.Http.Json;

using var client = new HttpClient { BaseAddress = new Uri("http://localhost:8025/") };

string[] queries =
[
    "subject:\"password reset\"",
    "to:jane.doe@example.com",
    "to:jane.doe@example.com subject:\"password reset\"",
    "has:attachment",
    "tag:orders",
    "reset -to:roger.roe@example.com"
];

foreach (var query in queries)
{
    string url = $"api/v1/search?query={Uri.EscapeDataString(query)}";
    var res = await client.GetFromJsonAsync<SearchResponse>(url);

    Console.WriteLine($"{query} ({res!.MessagesCount} found)");

    foreach (var m in res.Messages)
    {
        Console.WriteLine($"  {m.To[0].Address}: {m.Subject}");
    }
}

record SearchResponse(
    [property: System.Text.Json.Serialization.JsonPropertyName("messages_count")] int MessagesCount,
    List<MessageSummary> Messages);
record MessageSummary(string ID, List<Mailbox> To, string Subject);
record Mailbox(string Name, string Address);

We run several search queries.

string url = $"api/v1/search?query={Uri.EscapeDataString(query)}";

The query contains spaces, quotes, and other special characters, so we must escape it with Uri.EscapeDataString.

record SearchResponse(
    [property: System.Text.Json.Serialization.JsonPropertyName("messages_count")] int MessagesCount,
    List<MessageSummary> Messages);

The messages_count field contains the number of matching messages. Its name uses snake case, so we map it with the JsonPropertyName attribute.

$ dotnet run
subject:"password reset" (2 found)
  jane.doe@example.com: Password reset
  roger.roe@example.com: Password reset
to:jane.doe@example.com (3 found)
  jane.doe@example.com: Password reset
  jane.doe@example.com: Welcome to the shop
  jane.doe@example.com: Your order #1024
to:jane.doe@example.com subject:"password reset" (1 found)
  jane.doe@example.com: Password reset
has:attachment (1 found)
  jane.doe@example.com: Your order #1024
tag:orders (1 found)
  jane.doe+orders@example.com: Order #1025 shipped
reset -to:roger.roe@example.com (1 found)
  jane.doe@example.com: Password reset

Note that jane.doe+orders@example.com does not match to:jane.doe@example.com. Mailpit automatically tags messages sent to such plus addresses with the part after the plus sign; this is how the message got the orders tag. Plus addresses are a handy way to separate the emails of individual tests.

Managing messages

The API can also tag messages, change their read status, and delete them.

Program.cs
using System.Net.Http.Json;
using System.Text.Json.Serialization;

using var client = new HttpClient { BaseAddress = new Uri("http://localhost:8025/") };

var resets = await client.GetFromJsonAsync<SearchResponse>(
    $"api/v1/search?query={Uri.EscapeDataString("subject:\"password reset\"")}");

var ids = resets!.Messages.Select(m => m.ID).ToArray();

if (ids.Length == 0)
{
    Console.WriteLine("No password reset messages");
    return;
}

var res = await client.PutAsJsonAsync("api/v1/tags", new { IDs = ids, Tags = new[] { "security" } });
res.EnsureSuccessStatusCode();

res = await client.PutAsJsonAsync("api/v1/messages", new { IDs = ids, Read = true });
res.EnsureSuccessStatusCode();

res = await client.DeleteAsync($"api/v1/search?query={Uri.EscapeDataString("subject:newsletter")}");
res.EnsureSuccessStatusCode();

var tags = await client.GetFromJsonAsync<string[]>("api/v1/tags");
Console.WriteLine($"Tags: {string.Join(", ", tags!)}");

var info = await client.GetFromJsonAsync<AppInfo>("api/v1/info");
Console.WriteLine($"Messages: {info!.Messages}, unread: {info.Unread}");

foreach (var (tag, count) in info.Tags)
{
    Console.WriteLine($"  {tag}: {count}");
}

record SearchResponse(
    [property: JsonPropertyName("messages_count")] int MessagesCount,
    List<MessageSummary> Messages);
record MessageSummary(string ID, string Subject);
record AppInfo(int Messages, int Unread, Dictionary<string, int> Tags);

We tag the password reset messages and mark them as read, and we delete the newsletter.

if (ids.Length == 0)
{
    Console.WriteLine("No password reset messages");
    return;
}

This check is important. The endpoints for setting the read status and for deleting messages apply to all messages when the list of IDs is empty.

var res = await client.PutAsJsonAsync("api/v1/tags", new { IDs = ids, Tags = new[] { "security" } });

For simple request bodies, we use anonymous types. The PutAsJsonAsync method serializes the object to JSON and sends a PUT request. Setting tags replaces the existing tags of the messages.

res = await client.DeleteAsync($"api/v1/search?query={Uri.EscapeDataString("subject:newsletter")}");

A DELETE request to the search endpoint deletes all messages that match the query.

$ dotnet run
Tags: orders, security
Messages: 5, unread: 2
  orders: 1
  security: 2

Testing emails with NUnit

The main use of the API is in automated tests. The test sends an email through the application code, waits until the message arrives in Mailpit, and checks its content.

$ dotnet new nunit -o Shop.Tests
$ cd Shop.Tests
$ dotnet add package MailKit

We create an NUnit test project. In a real solution, the tested class would be in a separate project; here we keep everything in one project for brevity.

OrderMailer.cs
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;

namespace Shop.Tests;

public class OrderMailer(string host, int port)
{
    public async Task SendConfirmationAsync(string customerEmail, int orderId)
    {
        var message = new MimeMessage();
        message.From.Add(new MailboxAddress("Shop", "noreply@shop.example.com"));
        message.To.Add(MailboxAddress.Parse(customerEmail));
        message.Subject = $"Your order #{orderId}";

        var link = $"https://shop.example.com/orders/{orderId}";

        message.Body = new BodyBuilder
        {
            TextBody = $"Thank you for your order. Track it at {link}",
            HtmlBody = $"""<p>Thank you for your order.</p><p><a href="{link}">Track your order</a></p>"""
        }.ToMessageBody();

        using var client = new SmtpClient();

        await client.ConnectAsync(host, port, SecureSocketOptions.None);
        await client.SendAsync(message);
        await client.DisconnectAsync(true);
    }
}

This is the class under test. It sends an order confirmation with a link to the order. The SMTP host and port are passed to the constructor; in the tests, they point to Mailpit.

MailpitClient.cs
using System.Net.Http.Json;
using System.Text.Json.Serialization;

namespace Shop.Tests;

public record Mailbox(string Name, string Address);
public record MessageSummary(string ID, Mailbox From, List<Mailbox> To, string Subject);
public record SearchResponse(
    [property: JsonPropertyName("messages_count")] int MessagesCount,
    List<MessageSummary> Messages);
public record Message(string ID, Mailbox From, List<Mailbox> To, string Subject,
    string Text, string HTML);

public class MailpitClient(string baseUrl) : IDisposable
{
    private readonly HttpClient http = new() { BaseAddress = new Uri(baseUrl) };

    public async Task DeleteAllAsync()
    {
        var res = await http.DeleteAsync("api/v1/messages");
        res.EnsureSuccessStatusCode();
    }

    public async Task<List<MessageSummary>> SearchAsync(string query)
    {
        var url = $"api/v1/search?query={Uri.EscapeDataString(query)}";
        var res = await http.GetFromJsonAsync<SearchResponse>(url);

        return res!.Messages;
    }

    public async Task<Message> GetMessageAsync(string id) =>
        (await http.GetFromJsonAsync<Message>($"api/v1/message/{id}"))!;

    public async Task<Message> WaitForMessageAsync(string query, TimeSpan timeout)
    {
        using var cts = new CancellationTokenSource(timeout);

        while (true)
        {
            var found = await SearchAsync(query);

            if (found.Count > 0)
            {
                return await GetMessageAsync(found[0].ID);
            }

            try
            {
                await Task.Delay(100, cts.Token);
            }
            catch (OperationCanceledException)
            {
                throw new TimeoutException($"No message matching '{query}' within {timeout}");
            }
        }
    }

    public void Dispose() => http.Dispose();
}

The MailpitClient class wraps the API calls used by the tests.

public async Task<Message> WaitForMessageAsync(string query, TimeSpan timeout)

Email delivery is asynchronous. In a real application, the email may be sent by a background job some time after the tested action. Instead of checking the mailbox once, we poll it every 100 ms until a matching message arrives or the timeout expires.

OrderMailerTests.cs
using System.Text.RegularExpressions;

namespace Shop.Tests;

public class OrderMailerTests
{
    private readonly MailpitClient mailpit = new("http://localhost:8025/");
    private readonly OrderMailer mailer = new("localhost", 1025);

    [SetUp]
    public async Task SetUp() => await mailpit.DeleteAllAsync();

    [OneTimeTearDown]
    public void TearDown() => mailpit.Dispose();

    [Test]
    public async Task SendsConfirmationToCustomer()
    {
        await mailer.SendConfirmationAsync("jane.doe@example.com", 1024);

        var msg = await mailpit.WaitForMessageAsync("to:jane.doe@example.com",
            TimeSpan.FromSeconds(5));

        using (Assert.EnterMultipleScope())
        {
            Assert.That(msg.Subject, Is.EqualTo("Your order #1024"));
            Assert.That(msg.From.Address, Is.EqualTo("noreply@shop.example.com"));
            Assert.That(msg.To.Select(a => a.Address), Is.EqualTo(new[] { "jane.doe@example.com" }));
        }
    }

    [Test]
    public async Task ContainsTrackingLink()
    {
        await mailer.SendConfirmationAsync("roger.roe@example.com", 2048);

        var msg = await mailpit.WaitForMessageAsync("subject:\"order #2048\"",
            TimeSpan.FromSeconds(5));

        var match = Regex.Match(msg.HTML, "href=\"([^\"]+)\"");

        Assert.That(match.Success, Is.True);
        Assert.That(match.Groups[1].Value,
            Is.EqualTo("https://shop.example.com/orders/2048"));
        Assert.That(msg.Text, Does.Contain("https://shop.example.com/orders/2048"));
    }

    [Test]
    public async Task SendsOneEmailPerOrder()
    {
        await mailer.SendConfirmationAsync("lucy.smith@example.com", 1);
        await mailer.SendConfirmationAsync("lucy.smith@example.com", 2);

        await mailpit.WaitForMessageAsync("subject:\"order #2\"", TimeSpan.FromSeconds(5));
        var all = await mailpit.SearchAsync("to:lucy.smith@example.com");

        Assert.That(all, Has.Count.EqualTo(2));
    }
}

We have three tests.

[SetUp]
public async Task SetUp() => await mailpit.DeleteAllAsync();

Before each test, we delete all messages, so that the tests do not see each other's emails.

var msg = await mailpit.WaitForMessageAsync("to:jane.doe@example.com",
    TimeSpan.FromSeconds(5));

We wait for the confirmation and check the subject, the sender, and the recipients. The EnterMultipleScope method makes NUnit report all failed assertions, not only the first one.

var match = Regex.Match(msg.HTML, "href=\"([^\"]+)\"");

In the second test, we extract the link from the HTML body. For more complex messages, an HTML parser such as AngleSharp is a better choice than a regular expression.

var all = await mailpit.SearchAsync("to:lucy.smith@example.com");

Assert.That(all, Has.Count.EqualTo(2));

The third test checks that the application does not send duplicate emails.

$ dotnet test
...
Passed!  - Failed:     0, Passed:     3, Skipped:     0, Total:     3, Duration: 243 ms - Shop.Tests.dll (net10.0)

The tests share one mailbox, so they must not run in parallel; NUnit runs the tests sequentially unless we enable parallelism with the Parallelizable attribute. For parallel tests, we would give each test its own plus address and search by it instead of deleting all messages.

Link and HTML checks

Mailpit can check the links in a message and the support of its HTML and CSS in email clients. Email clients support only a subset of HTML and CSS, and the support differs a lot between them.

Program.cs
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;

var html = """
    <html>
    <head>
    <style>
      .products { display: flex; gap: 16px; }
      .product { border-radius: 8px; box-shadow: 0 2px 4px #ccc; }
    </style>
    </head>
    <body>
      <h1>New products this week</h1>
      <div class="products">
        <div class="product"><a href="https://example.com/">Lamp</a></div>
        <div class="product"><a href="https://example.com/products/chair">Chair</a></div>
        <div class="product"><a href="https://shop.invalid/table">Table</a></div>
      </div>
      <svg width="20" height="20"><circle cx="10" cy="10" r="8" fill="red" /></svg>
    </body>
    </html>
    """;

var message = new MimeMessage();
message.From.Add(new MailboxAddress("Shop", "noreply@shop.example.com"));
message.To.Add(new MailboxAddress("Lucy Smith", "lucy.smith@example.com"));
message.Subject = "Weekly newsletter";
message.Body = new BodyBuilder { HtmlBody = html }.ToMessageBody();

using var client = new SmtpClient();

await client.ConnectAsync("localhost", 1025, SecureSocketOptions.None);
await client.SendAsync(message);
await client.DisconnectAsync(true);

Console.WriteLine("Newsletter sent");

We send a newsletter that uses flexbox, shadows, and an embedded SVG image. One link points to a page that does not exist, another to a domain that does not exist.

Program.cs
using System.Net.Http.Json;

using var client = new HttpClient { BaseAddress = new Uri("http://localhost:8025/") };

var links = await client.GetFromJsonAsync<LinkCheck>("api/v1/message/latest/link-check");

Console.WriteLine($"Broken links: {links!.Errors}");

foreach (var link in links.Links)
{
    Console.WriteLine($"  {link.StatusCode,3} {link.Status,-14} {link.URL}");
}

var html = await client.GetFromJsonAsync<HtmlCheck>("api/v1/message/latest/html-check");

Console.WriteLine();
Console.WriteLine($"HTML support: {html!.Total.Supported:F1} % supported, " +
    $"{html.Total.Partial:F1} % partial, {html.Total.Unsupported:F1} % unsupported");

foreach (var w in html.Warnings.OrderByDescending(w => w.Score.Unsupported).Take(5))
{
    Console.WriteLine($"  {w.Title,-26} unsupported in {w.Score.Unsupported:F0} % of clients");
}

record LinkCheck(int Errors, List<Link> Links);
record Link(string URL, int StatusCode, string Status);

record HtmlCheck(Score Total, List<Warning> Warnings);
record Warning(string Title, string URL, Score Score);
record Score(double Supported, double Partial, double Unsupported);

We run both checks on the newest message.

var links = await client.GetFromJsonAsync<LinkCheck>("api/v1/message/latest/link-check");

The link check requests each link in the message and reports its status. With the follow=true query parameter, it also follows redirects.

var html = await client.GetFromJsonAsync<HtmlCheck>("api/v1/message/latest/html-check");

The HTML check compares the HTML and CSS features used in the message with the data from caniemail.com. It returns the overall support and a warning for each problematic feature. Each warning contains a score and detailed results for individual email clients and their versions.

$ dotnet run
Broken links: 2
  200 OK             https://example.com/
  404 Not Found      https://example.com/products/chair
    0 no such host   https://shop.invalid/table

HTML support: 81.7 % supported, 6.1 % partial, 12.1 % unsupported
  Embedded <svg> image       unsupported in 58 % of clients
  box-shadow                 unsupported in 48 % of clients
  gap, column-gap, row-gap   unsupported in 40 % of clients
  <body> element             unsupported in 33 % of clients
  display:flex               unsupported in 31 % of clients

The percentages refer to the email client versions in the caniemail.com database. There is also a /sa-check endpoint, which scores the message with SpamAssassin; it requires a SpamAssassin server and the --enable-spamassassin option.

Simulating SMTP errors

A real mail server may reject a message, for instance because the mailbox does not exist or because the server is overloaded. With the Chaos feature, we can make Mailpit return such errors and test how the application handles them. The feature must be enabled with the --enable-chaos option.

Program.cs
using System.Net.Http.Json;
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;

using var http = new HttpClient { BaseAddress = new Uri("http://localhost:8025/") };

var triggers = new ChaosTriggers(Recipient: new ChaosTrigger(550, 100));
var res = await http.PutAsJsonAsync("api/v1/chaos", triggers);
res.EnsureSuccessStatusCode();

Console.WriteLine($"Chaos enabled: {await res.Content.ReadAsStringAsync()}");

var message = new MimeMessage();
message.From.Add(new MailboxAddress("Shop", "noreply@shop.example.com"));
message.To.Add(new MailboxAddress("Jane Doe", "jane.doe@example.com"));
message.Subject = "Your order #1026";
message.Body = new TextPart("plain") { Text = "Thank you for your order." };

using var smtp = new SmtpClient();

try
{
    await smtp.ConnectAsync("localhost", 1025, SecureSocketOptions.None);
    await smtp.SendAsync(message);
}
catch (SmtpCommandException e)
{
    Console.WriteLine($"Error: {e.ErrorCode}");
    Console.WriteLine($"Status: {(int) e.StatusCode} {e.StatusCode}");
    Console.WriteLine($"Mailbox: {e.Mailbox}");
    Console.WriteLine($"Message: {e.Message}");
}
finally
{
    await smtp.DisconnectAsync(true);
    await http.PutAsJsonAsync("api/v1/chaos", new ChaosTriggers());
}

record ChaosTrigger(int ErrorCode, int Probability);
record ChaosTriggers(ChaosTrigger? Sender = null, ChaosTrigger? Recipient = null,
    ChaosTrigger? Authentication = null);

We make Mailpit reject all recipients with the 550 error code and try to send a message.

var triggers = new ChaosTriggers(Recipient: new ChaosTrigger(550, 100));
var res = await http.PutAsJsonAsync("api/v1/chaos", triggers);

There are three triggers: Sender (the MAIL FROM command), Recipient (RCPT TO), and Authentication (AUTH). Each trigger has an SMTP error code and a probability in percent.

catch (SmtpCommandException e)

MailKit throws an SmtpCommandException when the server rejects a command. The ErrorCode property tells which part of the transaction failed, the StatusCode property contains the SMTP status code, and the Mailbox property the rejected address.

await http.PutAsJsonAsync("api/v1/chaos", new ChaosTriggers());

At the end, we reset the triggers. A trigger that is not specified is disabled.

$ dotnet run
Chaos enabled: {"Sender":{"ErrorCode":451,"Probability":0},"Recipient":{"ErrorCode":550,"Probability":100},"Authentication":{"ErrorCode":535,"Probability":0}}

Error: RecipientNotAccepted
Status: 550 MailboxUnavailable
Mailbox: "Jane Doe" <jane.doe@example.com>
Message: Chaos recipient error

SMTP status codes starting with 5 are permanent errors; codes starting with 4 are temporary, and the sender should try again later. In the next example, we simulate temporary errors that occur in half of the attempts and retry them.

Program.cs
using System.Net.Http.Json;
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;

using var http = new HttpClient { BaseAddress = new Uri("http://localhost:8025/") };
await http.PutAsJsonAsync("api/v1/chaos", new { Recipient = new { ErrorCode = 451, Probability = 50 } });

using var smtp = new SmtpClient();
await smtp.ConnectAsync("localhost", 1025, SecureSocketOptions.None);

try
{
    for (int orderId = 1; orderId <= 5; orderId++)
    {
        var message = new MimeMessage();
        message.From.Add(new MailboxAddress("Shop", "noreply@shop.example.com"));
        message.To.Add(new MailboxAddress("Jane Doe", "jane.doe@example.com"));
        message.Subject = $"Your order #{orderId}";
        message.Body = new TextPart("plain") { Text = "Thank you for your order." };

        int attempts = await SendWithRetryAsync(smtp, message, maxAttempts: 4);
        Console.WriteLine($"Order #{orderId}: sent after {attempts} attempt(s)");
    }
}
finally
{
    await smtp.DisconnectAsync(true);
    await http.PutAsJsonAsync("api/v1/chaos", new { });
}

static async Task<int> SendWithRetryAsync(SmtpClient smtp, MimeMessage message, int maxAttempts)
{
    for (int attempt = 1; ; attempt++)
    {
        try
        {
            await smtp.SendAsync(message);
            return attempt;
        }
        catch (SmtpCommandException e) when ((int) e.StatusCode is >= 400 and < 500
            && attempt < maxAttempts)
        {
            Console.WriteLine($"  attempt {attempt}: {(int) e.StatusCode} {e.Message}, retrying");
            await Task.Delay(TimeSpan.FromMilliseconds(200 * attempt));
        }
    }
}

We send five messages; each one is retried up to three times.

catch (SmtpCommandException e) when ((int) e.StatusCode is >= 400 and < 500
    && attempt < maxAttempts)

The exception filter catches only temporary errors and only while we have attempts left; other exceptions propagate to the caller. After a rejected recipient, the SMTP connection remains usable, so we can retry on the same connection.

await Task.Delay(TimeSpan.FromMilliseconds(200 * attempt));

We wait a little longer before each attempt. Real applications use longer delays, usually with an exponential backoff.

$ dotnet run
Order #1: sent after 1 attempt(s)
Order #2: sent after 1 attempt(s)
Order #3: sent after 1 attempt(s)
  attempt 1: 451 Chaos recipient error, retrying
  attempt 2: 451 Chaos recipient error, retrying
  attempt 3: 451 Chaos recipient error, retrying
Order #4: sent after 4 attempt(s)
Order #5: sent after 1 attempt(s)

The errors are random, so the output differs on each run.

Sending messages through the API

Messages can also be stored in Mailpit with a POST request instead of SMTP. This is useful for preparing test data or for testing the processing of incoming emails.

Program.cs
using System.Net.Http.Json;

using var client = new HttpClient { BaseAddress = new Uri("http://localhost:8025/") };

byte[] chart = await File.ReadAllBytesAsync("chart.png");

var request = new
{
    From = new { Email = "reports@shop.example.com", Name = "Reports" },
    To = new[] { new { Email = "manager@example.com", Name = "Manager" } },
    Subject = "Monthly sales report",
    Text = "The sales chart is attached.",
    HTML = "<p>The sales chart is attached.</p>",
    Tags = new[] { "reports" },
    Attachments = new[]
    {
        new { Filename = "chart.png", ContentType = "image/png",
              Content = Convert.ToBase64String(chart) }
    }
};

var res = await client.PostAsJsonAsync("api/v1/send", request);
res.EnsureSuccessStatusCode();

var sent = await res.Content.ReadFromJsonAsync<SendResponse>();
Console.WriteLine($"Stored as {sent!.ID}");

var msg = await client.GetFromJsonAsync<Message>($"api/v1/message/{sent.ID}");
Console.WriteLine($"{msg!.Subject}, tags: {string.Join(", ", msg.Tags)}, " +
    $"attachments: {msg.Attachments.Count}");

record SendResponse(string ID);
record Message(string Subject, List<string> Tags, List<object> Attachments);

We send a report with a tag and an attachment.

Attachments = new[]
{
    new { Filename = "chart.png", ContentType = "image/png",
          Content = Convert.ToBase64String(chart) }
}

The content of attachments is passed as a Base64-encoded string.

var sent = await res.Content.ReadFromJsonAsync<SendResponse>();

The response contains the ID of the stored message, which we use to read the message back.

$ dotnet run
Stored as 77g9JYL3DK7fFMS438cuSE
Monthly sales report, tags: reports, attachments: 1

Other features

Source

Mailpit API v1 documentation

In this article we have tested emails sent from C# applications with Mailpit.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all C# tutorials.