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"})
)