All posts
Receive email in real time with C# and ASP.NET Core
Gabe 5 min read

Receive email in real time with C# and ASP.NET Core

An ASP.NET Core minimal API can receive new email as signed JSON instead of polling a mailbox. This C# example verifies the original request body with the MailKite SDK before decoding it, and identifies the durable-storage boundary you need before production. MailKite is the platform we build.

Email arrives upstreamSigned POST to ASP.NET Core↓ Retain HttpRequest.BodyVerify → JsonDocument.Parse↓Guard ValueKind → type → idLog id → demo JSON response
Start with the HTTP request, not an already-bound email DTO.

The runnable source and delivery checks live in the shared receiving-email demo repository.

This is the downloadable Program.cs. The C# receiving guide provides the project commands and domain setup.

using System.Text;
using System.Text.Json;
using MailKite;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var secret = Environment.GetEnvironmentVariable("MAILKITE_WEBHOOK_SECRET")
    ?? throw new InvalidOperationException("Set MAILKITE_WEBHOOK_SECRET");
if (string.IsNullOrWhiteSpace(secret)) throw new InvalidOperationException("Set MAILKITE_WEBHOOK_SECRET");
app.MapPost("/hooks/mailkite", async (HttpRequest request) => {
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer);
    var raw = Encoding.UTF8.GetString(buffer.ToArray());
    var signature = request.Headers["x-mailkite-signature"].ToString();
    if (!MailKiteClient.VerifyWebhook(signature, raw, secret)) return Results.Unauthorized();
    try {
        using var json = JsonDocument.Parse(raw);
        var item = json.RootElement;
        if (item.ValueKind != JsonValueKind.Object || !item.TryGetProperty("type", out var type)
            || type.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(type.GetString()))
            return Results.BadRequest();
        if (type.GetString() == "email.received") {
            if (!item.TryGetProperty("id", out var id) || id.ValueKind != JsonValueKind.String
                || string.IsNullOrWhiteSpace(id.GetString())) return Results.BadRequest();
            Console.WriteLine("received " + id.GetString());
        }
        // Demonstration only: persist/enqueue and deduplicate before production acknowledgement.
        return Results.Ok(new { status = "ok" });
    } catch (JsonException) { return Results.BadRequest(); }
});
app.Run();

Let the signature check see the original body

Minimal API model binding is convenient for normal JSON endpoints, but a webhook verifier needs the representation that was signed. Accept HttpRequest, copy its body once, verify it, and parse that same string. A serialized DTO may preserve values while changing whitespace, property order or Unicode escaping.

MailKiteClient.VerifyWebhook is a local check of the signature and its timestamp. It requires a webhook secret, not an API key. Set MAILKITE_WEBHOOK_SECRET through the host’s environment or secret manager before running the app.

The receiver validates ValueKind.String before calling GetString and uses TryGetProperty for required fields. Signed malformed JSON, invalid type, and an email.received event without a nonempty string id return 400 rather than throwing a property-access exception.

The using declaration keeps each JsonElement valid until the route finishes, then disposes the JsonDocument. Do not enqueue an element backed by that document for later use: save the raw payload or copy the values your worker needs. The response uses Results.Ok on an object so ASP.NET Core writes JSON, rather than returning a plain-text success marker.

If earlier middleware reads the body, configure buffering and reset the stream or arrange for this route to receive the original body first. Don’t assume all middleware leaves the request stream untouched. Set an appropriate request-size limit for the events you accept.

Build a minimal API, then configure the receiving address

The public NuGet feed currently returns no package versions for MailKite, so dotnet add package MailKite cannot complete this walkthrough. The receiver was instead built and tested with .NET SDK 10.0.301 against the unchanged public SDK mirror at commit 4b401e7, whose project declares version 0.13.0. That is source-reference verification, not a NuGet installation test.

To reproduce that path, download the pinned SDK archive and extract it beside your ASP.NET Core project. Use a .NET 10 web project for the tested configuration and add a project reference to the extracted src/MailKite/MailKite.csproj. Replace the web project’s Program.cs with this receiver. The guide covers the receiving-domain setup.

The receiver reads its secret from the environment, but does not read PORT. Choose its address through ASP.NET Core host options: for example, dotnet run --no-launch-profile --urls http://127.0.0.1:3000. Disabling the launch profile keeps a generated development profile from selecting a different port during webhook testing.

Add and verify a receiving domain or available hosted subdomain in MailKite. Configure a route matching the receiving address and point it at the public HTTPS URL for /hooks/mailkite. A local tunnel can expose the development server. Use the route’s configured signing secret in the app.

For an existing mailbox, use Gmail forwarding or iCloud Mail forwarding. The original mailbox stays at its provider. Live means a pushed event after email arrival, with SMTP and forwarding delays; it does not mean a guaranteed timing bound.

Turn the demonstration into durable acceptance

This verification/logging demo logs the message id and returns 200, including on repeated ids. Before production, write the event into your database or queue, deduplicate by id, and await successful durable acceptance before returning Results.Ok.

Production extensionVerify → unique inbox insertDuplicate id → reuse inbox row↓ Await successful commitThen send 200↓Worker performs taskRecord durable completion
A hosted background service still needs durable input to survive process replacement.

Use the stable message id as a unique database key. A replay can then return success without creating another inbox row. Do not launch an untracked Task.Run, return 200, and treat the task as durable. An external side effect needs its own idempotency strategy in addition to the inbox key.

The delivery reference describes replay. Test a valid signed delivery, a modified-body delivery, a duplicate id, and a failed storage write. A 200 should mean your app accepted the work; it should not precede a write that might fail.

JSON fields and mail identity remain separate concerns

Sender and recipient values are address objects. The subject, original text body and authentication verdicts can be null. Read text, falling back to textFromHtml, and process required attachment downloads in the worker before their signed URLs expire.

A signature proves MailKite delivered this HTTP request, not that the email’s sender is trustworthy. Use the event’s mail-authentication verdicts separately. Forwarding can affect those results.

Postmark inbound or SES receipt rules can deliver application-domain mail by a different route. Self-hosted SMTP plus a MIME parser gives more control and more operations to own. Gmail’s API is a better fit when your app needs mailbox labels or read-state synchronization rather than a new-message feed.

Continue with the C# guide, event reference, and downloadable Program.cs.

Discuss this post: Hacker News Share on X Share on LinkedIn

Related posts