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.
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.
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.