ruby / postmark

Postmark is an email service for outbound transactional email and parsed inbound webhooks.

I use a thin HTTP client for the two Postmark endpoints I need: deliver an outbound email and read an inbound email.

Client

Postmark::Client:

require "http"
require "json"

module Postmark
  API_TOKEN = ENV.fetch("POSTMARK_API_TOKEN")

  class Client
    def deliver(from:, to:, subject:, html_body:, cc: nil)
      post("/email", {
        "From" => from,
        "To" => to,
        "Cc" => cc,
        "Subject" => subject,
        "HtmlBody" => html_body
      })
    end

    def details(message_id)
      get("messages/inbound/#{message_id}")
    end

    private def post(path, params)
      resp = HTTP
        .timeout(10)
        .headers(
          "Accept" => "application/json",
          "Content-Type" => "application/json",
          "X-Postmark-Server-Token" => API_TOKEN
        )
        .post("https://api.postmarkapp.com#{path}", json: params)
      if resp.code / 100 != 2
        return [nil, resp.code.to_s]
      end

      [JSON.parse(resp.body), nil]
    end

    private def get(path)
      resp = HTTP
        .timeout(10)
        .headers(
          "Accept" => "application/json",
          "X-Postmark-Server-Token" => API_TOKEN
        )
        .get("https://api.postmarkapp.com/#{path}")
      if resp.code / 100 != 2
        return [nil, resp.status.to_s]
      end

      [JSON.parse(resp.body), nil]
    end
  end
end

The methods return [data, err] tuples. Production code adds Sentry logging and rescues for HTTP::TimeoutError and JSON::ParserError; I've trimmed those here for clarity.

Outbound

Senders inject the client so tests can pass a stub without threading one through every caller:

class SendReport
  def initialize(db)
    @db = db
  end

  def call(report_id:, recipients:, p: Postmark::Client.new)
    # ...build subject and html_body...

    _, err = p.deliver(
      from: "App <[email protected]>",
      to: recipients.join(", "),
      subject: "Report",
      html_body: html_body
    )
    if err
      return "err: email delivery failed: #{err}"
    end

    "ok"
  end
end

Inbound webhook

Postmark posts parsed inbound emails to a webhook. Authenticate it with Basic auth using credentials configured in the Postmark dashboard:

class Inbound < PublicHandler
  def handle
    if !valid_basic_auth?
      return [401, {"WWW-Authenticate" => 'Basic realm="Postmark"'}, []]
    end

    payload = JSON.parse(@req.body.read)
    Mail::ProcessInbound.new(db).call(json: payload)
    head 200
  end

  private def valid_basic_auth?
    auth = @req.env["HTTP_AUTHORIZATION"]
    if auth.nil? || !auth.start_with?("Basic ")
      return false
    end

    decoded = Base64.decode64(auth.sub("Basic ", ""))
    username, password = decoded.split(":", 2)

    expected_user = ENV.fetch("POSTMARK_INBOUND_USERNAME")
    expected_pass = ENV.fetch("POSTMARK_INBOUND_PASSWORD")

    user_ok = Rack::Utils.secure_compare(username.to_s, expected_user)
    pass_ok = Rack::Utils.secure_compare(password.to_s, expected_pass)
    user_ok && pass_ok
  end
end

Rack::Utils.secure_compare avoids timing attacks on credential comparison.

Two subtleties matter:

Verify DKIM

Basic auth covers transport. Postmark verifies DKIM at ingress and reports the result in Authentication-Results. Require dkim=pass from an allowed signing domain to authenticate the message itself:

headers = json.fetch("Headers", [])
auth_results = headers
  .select { |h| h["Name"] == "Authentication-Results" }
  .map { |h| h["Value"].to_s }

ok = auth_results.any? do |v|
  v.match?(/\bdkim=pass\b/i) &&
    v.match?(/\b(?:header\.)?d=example\.com\b/i)
end
if !ok
  return "err: missing or failed DKIM signature"
end

The signing key lives with the domain's outbound mail infrastructure. A forged direct-to-Postmark message spoofing From: cannot pass this check.

Idempotency

Postmark retries failed webhooks up to 10 times. The handler must be idempotent. Record every message ID on first arrival and reject duplicates:

def call(json:)
  msg_id = json.dig("MessageID")

  dup = db.exec(<<~SQL, [msg_id]).first
    SELECT result
    FROM postmark_inbounds
    WHERE message_id = $1
  SQL
  if dup
    return "err: duplicate message"
  end

  # ...process...

  upsert(msg_id, "ok")
end

private def upsert(msg_id, result)
  db.exec(<<~SQL, [msg_id, result])
    INSERT INTO postmark_inbounds (message_id, result)
    VALUES ($1, $2)
    ON CONFLICT (message_id) DO UPDATE SET result = EXCLUDED.result
  SQL
end

Recording every result also enables ops queries: list failed messages, replay one through the pipeline, audit by date range.

Tests

Tests run on a custom framework. Stub the HTTP boundary with webmock:

stub_request(:post, "https://api.postmarkapp.com/email")
  .to_return(
    status: 200,
    body: JSON.generate({MessageID: "abc123"})
  )

← All articles