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

Receive email in real time with Ruby

Verify and log inbound email in Ruby with Sinatra and the MailKite SDK. This runnable demo validates signed JSON; add durable storage and deduplication before production acknowledgement.

Email arrives upstreamSigned POST to Sinatra↓ Read request.body onceSDK verifies raw bytes↓ Parse JSON → validate fieldsLog id → demo JSON response
The raw stream, not the Ruby hash, is the signature input.

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

MailKite is the platform we build. The Ruby receiving guide has a downloadable receiver and setup checklist. Live means push after arrival, with SMTP delays rather than an exact latency guarantee.

require "sinatra"
require "json"
require "mailkite"
SECRET = ENV.fetch("MAILKITE_WEBHOOK_SECRET")
abort "Set MAILKITE_WEBHOOK_SECRET" if SECRET.strip.empty?
CLIENT = Mailkite::Client.new("unused-for-verify")
set :port, ENV.fetch("PORT", "3000").to_i
set :host_authorization, { permitted_hosts: ["localhost", "127.0.0.1", ENV.fetch("WEBHOOK_HOST", "localhost")] }

post "/hooks/mailkite" do
  raw = request.body.read
  halt 401 unless CLIENT.verifyWebhook(request.env["HTTP_X_MAILKITE_SIGNATURE"] || "", raw, SECRET)
  begin
    event = JSON.parse(raw)
  rescue JSON::ParserError
    halt 400
  end
  halt 400 unless event.is_a?(Hash) && event["type"].is_a?(String) && !event["type"].strip.empty?
  if event["type"] == "email.received"
    halt 400 unless event["id"].is_a?(String) && !event["id"].strip.empty?
    puts "received #{event['id']}"
  end
  # Demonstration only: persist/enqueue and deduplicate before acknowledging in production.
  content_type :json
  Mailkite.reply_ok
end

This example uses Sinatra because the request-body lifecycle is visible in a few lines. The same boundary applies if your Rails app uses a controller action: verify the exact bytes first, then decode JSON.

Install the Ruby SDK and Sinatra

Use Ruby 3.2+ rather than macOS’s bundled Ruby 2.6. This receiver passed the local signed-request checks with Ruby 3.4.7, mailkite 0.20.0, Sinatra 4.2.1, Rackup 2.3.1 and Puma 8.0.2 from RubyGems. Rackup and Puma provide the server that runs the classic Sinatra script; installing only Sinatra and the SDK is not the complete runtime setup.

gem install mailkite sinatra rackup puma

Save the receiver as receive.rb and set the webhook secret from your domain’s MailKite settings. The SDK client uses a placeholder key because local verification does not call the API:

MAILKITE_WEBHOOK_SECRET=whsec_your_domain_secret \
ruby receive.rb

Keep the webhook secret out of source control. It authenticates deliveries to this endpoint.

Read and validate the signed event

request.body.read consumes Sinatra’s request stream. Do it once, verify those raw bytes, and parse the same string afterward. If you call a JSON parser first and then serialize the object again, insignificant formatting changes can invalidate the signature.

This is a verification/logging demo. It rejects signed malformed JSON, a missing or non-string type, and an email.received event without a nonempty string id with 400. It logs valid message ids and returns the SDK’s JSON acknowledgement, but does not save or deduplicate them. Add durable storage before acknowledging production deliveries.

JSON.parse returns Ruby objects, not a validated email model. A valid JSON array still needs rejection, and calling strip on a numeric id would raise. The short-circuit guards check Hash and String first. halt stops the route immediately, so rejected events never reach the logging line or success response.

Make retries harmless

Production extensionMessage id → unique inbox rowRepeat id → reuse stored row↓ Commit durable acceptanceThen acknowledge↓Worker processes saved work
The logging example needs a durable inbox before production use.

A webhook sender may retry when your endpoint times out or returns an error. The same inbound message keeps the same id across retries, so put a unique constraint on that identifier or use it as the idempotency key in your queue. Persist the work before returning the success acknowledgement; otherwise the sender can stop retrying even though your application never saved the event.

Do not run slow attachment processing inline in the Sinatra request. Store the event or enqueue a job, acknowledge it, then fetch attachment URLs in the background. The signed attachment URL is time-limited; download anything you need to retain while it is valid.

Using Rails instead

Rails can receive the same webhook through a controller. Exempt only that endpoint from browser CSRF checks, because the caller is a mail provider rather than a browser form, and use the MailKite signature as the authentication check. Read request.raw_post before parsing; do not trust a parsed-and-reserialized body. Keep the action narrow and hand verified work to Active Job or another durable queue.

The Ruby SDK also has an ActionMailer integration, but that is for sending email. Receiving remains an ordinary HTTP controller: MX records deliver mail to the provider, and the provider POSTs the parsed event to your route.

Configure the receiving domain

Sinatra 4 checks the request’s hostname before your route runs. For a tunnel or deployed host, set WEBHOOK_HOST to that exact public hostname, without the scheme or path. The example permits that host plus localhost; it does not turn host protection off. A proxy preserving the public Host header otherwise can produce “Host not permitted” before the signature verifier runs. Test through the public URL after changing this setting, not only through localhost.

For an existing mailbox instead of a new application address, use Gmail forwarding or iCloud Mail forwarding. Forwarding does not synchronize mailbox state or authorize sending as the original address. Postmark inbound is another hosted option; SES receipt rules or a self-hosted SMTP bridge put more of the parsing and delivery pipeline in your control.

Add and verify the domain in MailKite, publish the MX record it returns, then create a webhook route pointing at your public HTTPS URL. During local development, expose the Sinatra port through a secure tunnel and use the resulting HTTPS URL. Send a test event before sending a real message.

For a runnable SDK example, see 02_receive_webhook.rb. The complete email-to-webhook guide covers the shared payload, while verify inbound webhooks with HMAC explains what the SDK checks.

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

Related posts