All posts
Receive email in real time with Elixir
Gabe 5 min read

Receive email in real time with Elixir

A Plug endpoint can receive signed JSON whenever a new email arrives. This Elixir walkthrough reads the raw body before any parser, verifies it with the MailKite SDK and shows how to separate HTTP acknowledgement from durable worker processing. MailKite is the platform we build.

Email arrives upstreamSigned POST to Plug / Cowboy↓ Read body → retain updated connSDK verifies complete bytes↓Jason.decode → validate type / idLog id → JSON on updated conn
Plug.Parsers does not run before verification in this receiver.

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

Save the downloadable script as receive.exs. Its dependencies are declared by Mix.install; the Elixir receiving guide covers execution and domain setup.

Mix.install([{:mailkite, "~> 0.13"}, {:plug_cowboy, "~> 2.7"}, {:jason, "~> 1.4"}])

defmodule ReceiveEmail do
  use Plug.Router
  plug :match
  plug :dispatch

  post "/hooks/mailkite" do
    # No Plug.Parsers here: read the signed raw bytes before decoding JSON.
    case Plug.Conn.read_body(conn, length: 10_000_000) do
      {:ok, raw, conn} ->
        signature = List.first(get_req_header(conn, "x-mailkite-signature")) || ""
        secret = System.fetch_env!("MAILKITE_WEBHOOK_SECRET")
        if MailKite.Webhook.verify_webhook(signature, raw, secret) do
          case Jason.decode(raw) do
            {:ok, %{"type" => type} = event} when is_binary(type) ->
              id = event["id"]
              if String.trim(type) == "" or
                   (type == "email.received" and (not is_binary(id) or String.trim(id) == "")) do
                send_resp(conn, 400, "invalid event")
              else
                if type == "email.received", do: IO.puts("received #{id}")
                # Demonstration only: persist/enqueue and deduplicate before production acknowledgement.
                conn
                |> put_resp_content_type("application/json")
                |> send_resp(200, MailKite.Webhook.reply_ok())
              end
            _ -> send_resp(conn, 400, "bad JSON")
          end
        else
          send_resp(conn, 401, "bad signature")
        end
      {:more, _, conn} -> send_resp(conn, 413, "body too large")
      {:error, _} -> send_resp(conn, 400, "body read failed")
    end
  end
  match _, do: send_resp(conn, 404, "not found")
end

if String.trim(System.fetch_env!("MAILKITE_WEBHOOK_SECRET")) == "",
  do: raise("Set MAILKITE_WEBHOOK_SECRET")
port = String.to_integer(System.get_env("PORT", "3000"))
{:ok, _} = Plug.Cowboy.http(ReceiveEmail, [], port: port)
Process.sleep(:infinity)

A partial body is not the signed body

A partial read is incomplete input. It isn’t a wrong signature. Keep those outcomes separate in request logs: a payload rejected by your size policy needs a different fix from a complete request signed with the wrong secret.

Plug.Conn.read_body can return :more when it has not read the complete request. This small receiver rejects that case rather than verifying a prefix. The length setting is a demo policy, not a MailKite attachment limit. A production body reader can accumulate chunks within a deliberate total-size limit.

The SDK verifier receives the signing header, the exact body string and the webhook secret. It checks freshness and MAC locally, without an API key. Only after that succeeds does Jason.decode parse the event.

Signed malformed JSON, invalid type, and an email.received event without a nonempty string id return 400. Success sends MailKite.Webhook.reply_ok() as JSON, piping the updated connection through the content-type and response functions.

Jason decodes object keys as strings, which is why the pattern matches %{"type" => type}. The is_binary guard runs before String.trim; the id check also short-circuits before trimming a null or numeric value. Keeping the conn returned by read_body preserves its body-read state. Each branch returns the resulting connection, as Plug expects.

In Phoenix, body parsing commonly happens in the endpoint before your controller runs. Configure a raw-body reader or a dedicated route pipeline that preserves the signed bytes. Reading conn.body_params and serializing it again does not reconstruct the signed request.

Start the receiver before wiring email to it

Use Elixir 1.18+ and a compatible OTP release. Supply MAILKITE_WEBHOOK_SECRET, then run elixir receive.exs. Expose local port 3000 through a development tunnel or deploy the receiver behind a public HTTPS host. Process.sleep(:infinity) only keeps this standalone demonstration alive; a normal OTP application owns its supervision tree.

The script compiled and passed local signed-request tests on Elixir 1.20.2 / OTP 29 with Hex packages mailkite 0.13.0, Plug Cowboy 2.9.0, Plug 1.20.3 and Jason 1.4.5. Mix.install fetches and compiles dependencies on the first run, before the listener starts. Move them into mix.exs and retain mix.lock when incorporating the receiver into an application.

Add and verify a MailKite receiving domain or hosted subdomain, then configure an address-matched webhook route to /hooks/mailkite. Use the exact DNS records returned for an owned domain. Send a signed test delivery before a real message so the HTTP path can be checked independently of mail routing.

An existing Gmail or iCloud mailbox can forward to that separate address. “Live” means an event after arrival, with normal SMTP and forwarding delays.

A supervised process is not a durable queue

Restarting is only half the job. A supervisor can bring a worker back, but it cannot recreate a request that lived only in the old worker’s memory. Put accepted events somewhere the replacement worker can read them, then let supervision recover the process that does the reading.

This verification/logging demo logs and returns 200, including on repeated ids. Before production, commit the event to a database or durably enqueue it with a unique message id. A process receiving a message in memory can restart before completing the task, even if it is supervised.

Supervisor restarts processUnsaved work is still lostProduction needs a durable boundary Unique id → durable inboxCommit before acknowledgement↓Restarted worker reads inboxRecover accepted work
Supervision and message durability complement each other.

A durable queue or an Ecto-backed inbox should reject duplicate ids without dispatching the same job again. Acknowledge only after acceptance commits. Worker retries and external side-effect idempotency remain separate concerns.

Download required attachments in that worker before their signed URLs expire. Avoid keeping the HTTP request open through long attachment processing or a model call. The delivery reference covers replay; verify that a replay doesn’t create another pending job.

Preserve the distinction between delivery and sender trust

Email fields can contain null values. Use the original text part when present, then textFromHtml, and handle address objects explicitly. A valid MailKite signature authenticates the webhook, not the identity claimed by the email sender. Consult the event’s independent authentication verdicts and your own policy.

If the job requires Gmail labels and mailbox state, use the Gmail API. Postmark inbound and SES receipt rules are other domain-receiving options. A self-hosted SMTP receiver can feed Plug too, but it also needs MIME parsing, request signing and retry delivery.

The Elixir guide, event reference and downloadable script are the implementation references for this example.

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

Related posts