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)

    Rack::Utils.secure_compare(username, ENV.fetch("POSTMARK_INBOUND_USERNAME")) &&
      Rack::Utils.secure_compare(password, ENV.fetch("POSTMARK_INBOUND_PASSWORD"))
  end
end

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

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