All posts
Receive email in real time with Go
Gabe 4 min read

Receive email in real time with Go

Verify and log inbound email with Go's net/http and the MailKite SDK. This runnable demo limits request size and validates signed JSON; add durable storage before production acknowledgement.

Email arrives upstreamSigned POST to net/http↓ Read r.Body within 10 MiBSDK verifies retained bytes↓json.Unmarshal → validateLog id → demo JSON response
Both verification and parsing use the same retained bytes.

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

MailKite is the platform we build. The Go receiving guide offers a downloadable receiver. The logging demonstration below shows the protocol; add durable acceptance before production use. Live delivery means push after arrival, with normal SMTP delays.

package main

import (
    "encoding/json"
    "io"
    "log"
    "net/http"
    "os"
    "strings"
    mailkite "github.com/mailkite/mailkite-go"
)

func main() {
    secret := os.Getenv("MAILKITE_WEBHOOK_SECRET")
    if strings.TrimSpace(secret) == "" { log.Fatal("Set MAILKITE_WEBHOOK_SECRET") }
    mk := mailkite.New("unused-for-verify")
    http.HandleFunc("/hooks/mailkite", func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost { w.WriteHeader(405); return }
        raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 10 << 20))
        if err != nil { w.WriteHeader(413); return }
        if !mk.VerifyWebhook(r.Header.Get("x-mailkite-signature"), string(raw), secret) {
            w.WriteHeader(401); return
        }
        var event struct {
            ID string `json:"id"`
            Type string `json:"type"`
            From struct { Address string `json:"address"` } `json:"from"`
            Subject *string `json:"subject"`
        }
        if json.Unmarshal(raw, &event) != nil || strings.TrimSpace(event.Type) == "" { w.WriteHeader(400); return }
        if event.Type == "email.received" {
            if strings.TrimSpace(event.ID) == "" { w.WriteHeader(400); return }
            log.Printf("received %s", event.ID)
        }
        // Demonstration only: persist/enqueue and deduplicate before acknowledging in production.
        w.Header().Set("Content-Type", "application/json")
        _, _ = io.WriteString(w, mk.ReplyOk())
    })
    port := os.Getenv("PORT")
    if port == "" { port = "3000" }
    log.Fatal(http.ListenAndServe(":" + port, nil))
}

Install the SDK

go mod init receive-email-demo
go get github.com/mailkite/mailkite-go

Save the receiver as main.go. Set MAILKITE_WEBHOOK_SECRET from the receiving domain’s settings. The SDK client uses a placeholder key for this local verifier:

MAILKITE_WEBHOOK_SECRET=whsec_your_domain_secret \
go run .

Validate before logging

Event fields are top-level: from and each element of to are address objects, and fields such as subject and text can be null. The struct uses a pointer for the optional subject. Signed malformed JSON, invalid field types, empty type, and a received event without a nonempty string id return 400.

Startup rejects a blank secret. The handler limits request bodies to 10 MiB and returns 413 on a read failure. This is a verification/logging demo: repeated ids will be logged again until you add durable storage and deduplication before the JSON acknowledgement.

Go’s decoder rejects a numeric id assigned to a string field, but JSON null leaves that field at its zero value. The explicit nonempty check catches both null and missing ids. Unknown fields are ignored, which lets the small logging struct coexist with the larger inbound payload; add explicit validation when your worker depends on more fields.

Treat retries as normal

Production extensionStable id → unique inbox entryRepeat id → no second row↓ Commit storage firstThen acknowledge↓Worker → idempotent actions
A unique storage key does not by itself deduplicate external actions.

If your handler returns a failure or does not answer in time, a delivery may be attempted again. Use the stable event id as a database unique key or queue idempotency key. A useful order is: verify, decode, write to durable storage, acknowledge. If you acknowledge before the write succeeds, a later storage failure can lose the message from your app’s point of view.

For larger payloads, configure an appropriate server-side request limit and return an error if reading fails. Do not silently truncate the body: verification must cover exactly what the provider sent.

http.MaxBytesReader reports excess data rather than returning a successful truncated payload. This demo treats every body-read error as 413; a production handler can distinguish a *http.MaxBytesError from a transport failure. The ListenAndServe call also uses the default server timeouts. Set deliberate read-header and idle timeouts when integrating it into your service.

Deploy and test

To receive from an existing mailbox, follow Gmail forwarding or iCloud Mail forwarding. Use Gmail’s API when labels or mailbox synchronization matter. Postmark inbound and SES receipt rules are other application-domain options; a self-hosted SMTP bridge requires your own MIME parsing and retry path.

Add and verify your domain, point its MX record to MailKite, and create a route for https://your-host/hooks/mailkite. Keep the secret in your deployment’s secret manager. Test with a signed test event, then send a message to an address matched by the route. The handler should reject a modified body and accept the untouched signed payload.

The Go SDK includes a runnable webhook example. For the payload and route model, read the receiving docs and email-to-webhook guide.

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

Related posts