Postmark with Rails and Solidus: safe transactional email in development, staging and production

TL;DR

Difficulty: Intermediate

  • Production sends through Postmark. Development opens emails in the browser with letter_opener. Test keeps them in an array. Staging copies production but an interceptor rewrites every recipient to one team inbox.
  • Keep each Postmark server token in Rails encrypted credentials, one file per environment, keys out of git. One Postmark server per environment.
  • Solidus emails go on the default transactional stream, newsletters on a broadcast stream.
  • Catch bounces and spam complaints by webhook, set up DKIM, a custom Return Path and DMARC, and read bounce and complaint rates per stream, not in total.

A Solidus shop sends order confirmations, shipping notices and password resets by itself, and every one of them must reach the customer in production and nobody at all anywhere else. This guide sets that up with Postmark step by step, from where the token lives to how to read the first month's bounce rate, using an invented coffee roaster in Galway.

One shop, four places where email can go wrong

An online shop sends two kinds of email. There is the newsletter, which you write and send on purpose. And there is everything the shop sends by itself: the order confirmation, the shipping notice, the password reset. Those are called transactional emails, because a transaction (an order, a login, a refund) triggers each one, and each goes to exactly one person.

Nobody praises transactional email, and everybody notices when it breaks. A customer who pays €38 for coffee and gets no confirmation does not suspect your mail server. They suspect you.

The same code runs in four places. Development is your laptop. Test is the automated test suite. Staging is a private copy of the shop for trying things. Production is the real shop. The goal: in production, every email reaches the customer. Everywhere else, no email ever reaches a customer, and you can still see exactly what would have been sent.

I use Postmark for production sending. It does one thing, delivering email from applications, and the Rails integration is a small gem. Most of what follows is plain Rails, so it carries over to any provider.

Worked example: what Salthill Roastery sends

Salthill Roastery is an invented coffee roaster in Galway. Aoife runs it with two part time packers, sells beans and subscriptions on a Solidus shop at salthillroastery.example, and takes about 1,450 orders a month at an average of €34.

Out of the box, Solidus sends emails when checkout completes, when an order ships, when an order is cancelled, and when an admin finishes a batch of promotion codes. Each has its own mailer class, which a setting lets you swap.1 The order confirmation comes from Spree::OrderMailer, and it is sent by a subscriber that listens for the order_finalized event and calls confirm_email(order).deliver_later, then marks the order so the email is never sent twice.2

So when a customer in Oranmore, Co. Galway, pays for a kilo of espresso beans, the checkout finishes, Solidus announces order_finalized, the subscriber queues the confirmation as a background job, and the job hands the email to whatever delivery method the environment uses. That last step is the one to control.

One detail matters later. The confirmation is addressed to the order's email, and it is also blind copied to the store's own bcc_email if one is set.3 Aoife has set one, so a copy of every confirmation lands in her inbox. Keep that in mind for staging.

Keep the Postmark token in encrypted credentials, never in git

Postmark identifies your application by a server API token sent with every request.4 Whoever has it can send email as your shop.

Rails has a built in place for secrets. It stores them in config/credentials.yml.enc, a file encrypted with a master key. Because the file is encrypted, it can go into version control, as long as the key itself is kept safe. The Rails guide says it plainly: do not commit your master key.5 You edit it with bin/rails credentials:edit.

Rails also lets you keep a separate credentials file per environment. Run the command with --environment and Rails creates an override file with its own key, which takes precedence over the global file in that environment.6 So the production token can only be decrypted by a machine holding the production key.

The postmark-rails README reads the token from credentials as postmark_api_token and passes it as api_token.7 The first version of this article wrote api_key. Use api_token.

An .env file is plain text, it gets copied around, and sooner or later someone commits it. I have lost count of the repositories where the first file I opened held a live API token.

Worked example: Salthill's two credentials files

Aoife's developer runs the command once per environment:

bin/rails credentials:edit --environment production
bin/rails credentials:edit --environment staging

In the production file, shown here decrypted, go the production server's token and a username and password for the webhook:

postmark_api_token: "the production server token from Postmark"
postmark_webhook:
  user: "postmark"
  password: "a long random string from bin/rails secret"

The staging file has the same keys with staging values. Development needs no Postmark token at all.

The two encrypted files go into git. The two key files do not. The production key is set on the production server as RAILS_MASTER_KEY, which Rails reads before any key file.6 The staging key lives only on staging. A laptop left behind in a café on Shop Street cannot send a single email as the shop.

One Postmark server per environment

In Postmark, a "server" is not a machine. It is a container inside your account, and the token you send email with belongs to the server.4 Separate environments, then, means separate servers.

Postmark offers two kinds. A Live server delivers email. A Sandbox server behaves exactly like a live one, with the activity log, the API and the webhooks all working, but never sends a message to a recipient. Messages go to a black hole and still show as delivered in the interface.8 You choose the type when you create the server, and you cannot change it afterwards.8 Sandbox messages still count towards your monthly sending volume.8

Worked example: Salthill's three servers

Aoife creates three servers in her Postmark account:

  • Salthill production, Live. Its token goes into the production credentials.
  • Salthill staging, Live. Aoife wants to see staging emails in her real Gmail on her phone. The interceptor makes sure only she gets them.
  • Salthill CI, Sandbox. For checking the whole path through Postmark's API, webhooks included, with no delivery at all.

If she did not care about seeing staging emails in a real inbox, the staging server would be a Sandbox one too. That is the stricter choice, and the one I would make for a shop with several people deploying.

Set the delivery method per environment

Action Mailer, the part of Rails that sends email, has a setting called delivery_method. The built in values are :smtp (the default), :sendmail, :test and :file, and you can plug in your own.9 The postmark-rails gem adds :postmark.7 Set it differently in each environment file and nobody has to remember anything.

In development I use the letter_opener gem. Instead of sending, it opens each email in your browser.10 You see the real thing, with the HTML layout and the links, and nothing leaves the laptop.

In the test suite, :test keeps every email in the array ActionMailer::Base.deliveries, so a test can check what was built.9 Production uses :postmark. Staging loads the production file and overrides only what must differ.

Worked example: Salthill's environment files

The Gemfile gets two lines:

gem "postmark-rails"
gem "letter_opener", group: :development

Development, in config/environments/development.rb, opens every email in the browser:

config.action_mailer.delivery_method = :letter_opener
config.action_mailer.perform_deliveries = true
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }

Production, in config/environments/production.rb, sends through Postmark:

config.action_mailer.delivery_method = :postmark
config.action_mailer.postmark_settings = {
  api_token: Rails.application.credentials.postmark_api_token
}
config.action_mailer.default_url_options = {
  host: "salthillroastery.example", protocol: "https"
}

Staging, in config/environments/staging.rb, reuses production and changes two things:

require_relative "production"

Rails.application.configure do
  config.action_mailer.default_url_options = {
    host: "staging.salthillroastery.example", protocol: "https"
  }
  config.action_mailer.interceptors = %w[StagingRecipientInterceptor]
end

Look at what staging does not say. It never names a token. It inherits the production line that reads postmark_api_token from credentials, and because staging has its own credentials file, the value it finds there is the staging server's token. One line of code, two secrets, decided by which key the machine holds.

Rewrite every recipient on staging with an interceptor

Staging holds real looking data, and people place test orders with whatever address comes to mind. Sooner or later one of those addresses belongs to a real person.

Action Mailer has a hook for exactly this. An interceptor is a class with a delivering_email(message) method that Rails calls just before an email is handed to the delivery method. The Rails guide's own example rewrites the recipient to a sandbox address and registers the interceptor only on staging.11

My version does three things. It sends every email to one inbox the team owns. It clears the copy and blind copy fields, because Solidus blind copies the store address on confirmations and you do not want that address flooded either.3 And it puts the original recipient in the subject, so whoever reads the inbox can see who the email was meant for.

Worked example: a test order on staging

The interceptor lives in app/mailers/staging_recipient_interceptor.rb:

class StagingRecipientInterceptor
  INBOX = "aoife+staging@salthillroastery.example"

  def self.delivering_email(message)
    original = Array(message.to).join(", ")
    message.to = [INBOX]
    message.cc = nil
    message.bcc = nil
    message.subject = "[STAGING for #{original}] #{message.subject}"
  end
end

Aoife's developer places an order on staging as conor.test@gmail.com, an address he made up on the spot without checking whether it exists. Here is what happens, step by step.

  1. Checkout completes, and the subscriber queues confirm_email for order R123456789.
  2. The job builds the email: to conor.test@gmail.com, blind copy to the store address, subject "Salthill Roastery Order Confirmation #R123456789".
  3. Before delivery, Rails calls the interceptor. The recipient becomes aoife+staging@salthillroastery.example, the blind copy is removed, and the subject becomes "[STAGING for conor.test@gmail.com] Salthill Roastery Order Confirmation #R123456789".
  4. The staging Postmark server delivers it to Aoife's inbox.

Whoever owns that Gmail address gets nothing. And if one day someone pastes the production token into the staging credentials by mistake, the interceptor still runs, because it is registered by the environment and not by the token. The worst outcome is staging emails in the production activity log. Untidy, but grand.

Look at every email in a preview before it is sent

The interceptor stops emails going to the wrong person. Previews stop the wrong email going to the right person.

A mailer preview is a small Ruby class that builds an email without sending it. Rails serves every preview at /rails/mailers on your development server, so you can open any email with real looking data.11 Preview methods return the email object, which means you call the mailer method without deliver_now or deliver_later.9 The Solidus guide confirms that its own emails can be previewed the same way.1

One subtlety: an interceptor does not run for previews. If you want one to, Rails has a separate register_preview_interceptor for that.9 For the staging rewrite you do not, since previews never leave the laptop.

Worked example: previewing the order confirmation

class OrderMailerPreview < ActionMailer::Preview
  def confirm_email
    order = Spree::Order.complete.last
    Spree::OrderMailer.confirm_email(order)
  end

  def cancel_email
    order = Spree::Order.complete.last
    Spree::OrderMailer.cancel_email(order)
  end
end

The default folder for previews is test/mailers/previews. Salthill uses RSpec, so the file goes in spec/mailers/previews, added to config.action_mailer.preview_paths.11

Aoife opens localhost:3000/rails/mailers/order_mailer/confirm_email and reads her confirmation the way a customer would. She notices that the delivery estimate still says "three to five days", which was true before she switched couriers. One minute to fix. Without the preview, a customer would have told her.

Follow one order confirmation through all four environments

Here is the whole arrangement as a single journey. The same confirmation, the same code, four different endings.

Where an order confirmation goes in each environmentA customer completes checkout, Solidus publishes order_finalized, a subscriber queues the confirmation email, and the environment decides the ending. Development opens it in the browser. Test keeps it in an array. Staging rewrites the recipient to the team inbox and sends it through the staging Postmark server. Production sends it through the production Postmark server to the customer, and Postmark reports the result back by webhook.Customer completes checkoutSolidus publishes the order_finalizedeventThe subscriber queues confirm_emailwith deliver_laterWhich environment is running?developmentDevelopment: letter_opener opens theemail in the browser. Nothing is sent.testTest: the email is kept inActionMailer::Base.deliveries for thetest to check.stagingStaging: the interceptor rewrites therecipient to the team inbox, then thestaging Postmark server delivers itthere.productionProduction: the production Postmarkserver delivers it to the customer.Postmark reports delivery, bounce orspam complaint back by webhook

Read it from the top. The first three boxes are identical everywhere, which is the point: you are testing the real code path, not a copy of it. Only the box after the question changes, and it changes because of a line in an environment file, not because anyone remembered to do something. The final box applies to the two environments that talk to Postmark, staging and production.

Test the real Postmark path without sending anything

Sooner or later you want to know the Postmark part itself works. Postmark gives you three tools, and they do different things.

The first is the test token. If you send the value POSTMARK_API_TEST as the server token, Postmark accepts the request and sends nothing.4 But the messages do not appear in your activity log, and you cannot simulate a bounce with it.12

The second is a Sandbox server: activity log and webhooks, no delivery.8

The third is the bounce testing domain. Send to hardbounce@bounce-testing.postmarkapp.com and Postmark generates a hard bounce, and adds the address to the stream's suppression list. There are similar addresses for a soft bounce, a spam notification and an ISP block.13 Bounces generated this way do not count towards your bounce limits or harm your sending reputation.12

There is also an older address, test@blackhole.postmarkapp.com, which accepts mail and throws it away. Every email to it counts towards your monthly volume.12 It is a recipient, not a sender, and that matters for the next worked example.

Worked example: checking the bounce handling before launch

Before Salthill's new shop goes live, the developer wants proof that a bounce really reaches the shop's webhook. Staging is no use for this, because the interceptor rewrites every recipient, including the test address. So he opens a Rails console on his laptop and, for that session only, points Action Mailer at the CI Sandbox server, whose webhook is set to the staging shop:

ActionMailer::Base.delivery_method = :postmark
ActionMailer::Base.postmark_settings = { api_token: "the CI sandbox token" }

delivery = Spree::OrderMailer.confirm_email(Spree::Order.complete.last)
delivery.message.to = ["hardbounce@bounce-testing.postmarkapp.com"]
delivery.message.bcc = nil
delivery.deliver_now

Within a minute Postmark records a hard bounce, marks the address inactive, and calls the webhook on staging, which stores the event. He repeats it with the soft bounce and spam notification addresses. Cost: three messages from the monthly allowance, and no harm to the reputation of salthillroastery.example.

Postmark's own testing guide warns against the opposite habit, sending test emails from your production domain to made up addresses, because the bounces damage the domain's reputation.12 That is the exact mistake the interceptor exists to prevent.

Keep transactional and broadcast mail in separate streams

Postmark splits the email inside each server into message streams. A transactional stream is for unique, high priority messages to a single recipient. A broadcast stream is for the same content sent to many people at once, such as a newsletter.14 Postmark keeps the two apart all the way down: transactional and broadcast messages use different sending IP addresses.14

Why care? A newsletter always earns a few spam clicks from people who forgot they signed up. Shared sending addresses would let those clicks drag your order confirmations towards the spam folder.

If you do not name a stream when sending, Postmark uses the server's default transactional stream.15 So every Solidus email lands in the right place without any change. For a mailer that sends in bulk, you name the broadcast stream. The postmark gem adds a message_stream setter to each email for this.16

Worked example: Salthill's newsletter

class NewsletterMailer < ApplicationMailer
  def issue(subscriber, issue)
    message.message_stream = "broadcasts" # the Stream ID set in Postmark
    @issue = issue
    mail(to: subscriber.email, subject: issue.subject)
  end
end

The order confirmation needs no change. The October newsletter goes out on the broadcast stream, so if five of 2,400 subscribers press the spam button, the mark lands on the broadcast addresses, and the next confirmation still leaves from the transactional ones.

A newsletter also needs the recipient's consent, which is a different topic. I cover it in rebuilding email capture for consent.

Listen for bounces and spam complaints with webhooks

A bounce is a delivery failure. A hard bounce means the address does not exist, and Postmark deactivates it automatically. A soft bounce is temporary, such as a full mailbox, and the address stays active.17 A spam complaint is recorded when the recipient presses the "this is spam" button. Postmark deactivates that address too, and will not let you reactivate it yourself.18

Postmark can tell your shop about each of these straight away with a webhook, an HTTP request to a web address in your application. The bounce webhook carries the bounced address, the bounce type, the stream, and whether the address was deactivated.19 The spam complaint webhook has the same shape with the type SpamComplaint.18

Protect the endpoint. Postmark does not sign its webhooks, so it recommends HTTPS, a username and password in the webhook address (basic authentication), and optionally allowing only Postmark's IP range.20 It retries failed deliveries on a schedule that runs for just under an hour, then stops.20

Worked example: Salthill's webhook controller

The route, then the controller:

post "/webhooks/postmark", to: "postmark_webhooks#create"
class PostmarkWebhooksController < ActionController::API
  include ActionController::HttpAuthentication::Basic::ControllerMethods
  before_action :authenticate

  def create
    payload = JSON.parse(request.raw_post)
    if %w[Bounce SpamComplaint].include?(payload["RecordType"])
      EmailEvent.create!(
        email: payload["Email"],
        kind: payload["Type"],
        message_stream: payload["MessageStream"],
        inactive: payload["Inactive"],
        occurred_at: payload["BouncedAt"] || Time.current
      )
    end
    head :ok
  end

  private

  def authenticate
    expected = Rails.application.credentials.postmark_webhook
    authenticate_or_request_with_http_basic do |user, password|
      ActiveSupport::SecurityUtils.secure_compare(user, expected[:user]) &
        ActiveSupport::SecurityUtils.secure_compare(password, expected[:password])
    end
  end
end

In Postmark, the webhook address is set to https://postmark:THE_PASSWORD@salthillroastery.example/webhooks/postmark, with the password from the production credentials. The staging server gets the same path on the staging domain with the staging password.

The useful part is what Aoife does with the events. Every morning the admin lists yesterday's orders whose confirmation hard bounced. Most are typos, gmial.com and the like. The packer rings the customer and corrects the address, no bother. In her first month that list held 30 orders: 30 customers who would otherwise have asked where their coffee was.

Prove the email is yours with DKIM, the Return Path and DMARC

A receiving server asks of every message: did this really come from the domain it claims? Three DNS records answer.

DKIM is a cryptographic signature. Postmark signs each message, and a TXT record in your DNS publishes the public key so receivers can check the signature. Postmark shows you the exact host name and value under Sender Signatures, DNS Settings, and verifies the record within 48 hours. Once the domain is verified, you can send from any address on it.21

SPF lists which servers may send for a domain. Here Postmark's advice surprises people: you no longer need to add Postmark to your own SPF record. Receivers check SPF against the Return Path domain, the address bounces go back to, and by default that is Postmark's own domain, which already passes.22

That default has one weakness. DMARC, the third record, passes only when SPF or DKIM is aligned, which means the domain that passed matches the domain in the From address.23 Postmark's default Return Path, pm_bounces@pm.mtasv.net, passes SPF but does not match your domain. A custom Return Path fixes that: you create a CNAME record, by default on the subdomain pm-bounces, pointing at pm.mtasv.net, and enter it in Postmark.24 Bounces still reach Postmark, and SPF now aligns with your domain.

DMARC itself is a TXT record at _dmarc that tells receivers what to do with mail that fails, and where to send reports. Postmark's guide starts at p=none, which only collects reports, and moves to quarantine and then reject once every legitimate sender passes.23 Gmail requires every sender to have SPF or DKIM, and anyone sending more than 5,000 messages a day to Gmail accounts to have SPF, DKIM and DMARC.25 A small shop is far below that line, but the records take ten minutes.

Worked example: Salthill's DNS records

Aoife's developer adds three records at her DNS provider:

Type   Host                                Value
TXT    (host shown by Postmark)._domainkey (public key shown by Postmark)
CNAME  pm-bounces                          pm.mtasv.net
TXT    _dmarc                              v=DMARC1; p=none; rua=mailto:dmarc@salthillroastery.example

Within a day Postmark shows DKIM and the Return Path as verified. A confirmation sent to a Gmail address now shows DKIM passing for salthillroastery.example, SPF passing for pm-bounces.salthillroastery.example, and DMARC passing because both belong to her domain.

She leaves DMARC at p=none for a month and reads the reports. They show one more sender she had forgotten, the accounting tool that emails invoices from her domain. Once that is set up properly too, she moves to p=quarantine. Straight to reject, and her invoices would have vanished.

Read the first month as rates, not counts

A count says nothing on its own. A hundred bounces out of 100,000 emails is excellent. A hundred out of 1,000 is a fire. What matters is the rate.

b=nbouncednsent×100c=ncomplaintsnsent×100b = \frac{n_{\text{bounced}}}{n_{\text{sent}}} \times 100 \qquad c = \frac{n_{\text{complaints}}}{n_{\text{sent}}} \times 100

Here bb is the bounce rate as a percentage, cc is the spam complaint rate as a percentage, nbouncedn_{\text{bounced}} is the number of emails that bounced, ncomplaintsn_{\text{complaints}} is the number of spam complaints, and nsentn_{\text{sent}} is the number of emails sent in the same period and stream. Each is a rate, events divided by opportunities, explained from scratch in Foundations: ratios and rates.

Postmark sets two lines for an account: a bounce rate below 10% of all emails sent, and a spam complaint rate below one in 1,000 emails, which is 0.1%. An account that drifts outside them is contacted and paused, with its messages queued, until the problem is solved.26 Google asks senders to keep the spam rate reported in its Postmaster Tools below 0.1% and never to reach 0.3%.25

Worked example: Salthill's first month on Postmark

These are invented numbers for the invented shop, every step shown.

Transactional stream:

  • Order confirmations: 1,450 sent, 41 bounced (30 hard, 11 soft), no complaints. Bounce rate: 41 divided by 1,450, times 100, is 2.8%.
  • Shipping notices: 1,420 sent, 9 bounced (all soft, full mailboxes), one complaint. Bounce rate: 9 divided by 1,420, times 100, is 0.6%. Why only 1,420 for 1,450 orders? The 30 addresses that hard bounced on the confirmation were deactivated, so Postmark did not send to them again.
  • Password resets: 330 sent, 14 bounced, no complaints. Bounce rate: 14 divided by 330, times 100, is 4.2%. Old accounts with addresses that no longer exist.

Transactional total: 3,200 sent, 64 bounced, one complaint. Bounce rate 64 divided by 3,200, times 100, is 2.0%. Complaint rate 1 divided by 3,200, times 100, is 0.03%. Delivered: 3,200 minus 64 is 3,136.

Broadcast stream:

  • Newsletter: 2,400 sent, 120 bounced (96 hard, 24 soft), five complaints. Bounce rate 120 divided by 2,400, times 100, is 5.0%. Complaint rate 5 divided by 2,400, times 100, is 0.21%. Delivered: 2,280.

Bounce rate by email type, first monthBounce rate for each type of email Salthill Roastery sent in its first month on Postmark: order confirmations 2.8%, shipping notices 0.6%, password resets 4.2%, newsletter 5.0%.Bounce rate by email type, first month0.0%2.5%5.0%7.5%10.0%2.8%Orderconfirmations0.6%Shippingnotices4.2%Passwordresets5.0%NewsletterBounce rateEmail type

Each bar along the horizontal axis is one type of email. The vertical axis is its bounce rate, bounced divided by sent for that type in the month. The newsletter bounces most, at 5.0%, because its list of 2,400 addresses was imported from Aoife's old platform and some of it was four years old. The shipping notice bounces least, because the confirmation before it already weeded out the bad addresses.

Now the trap. Add everything together: 5,600 sent, 184 bounced, six complaints. Bounce rate 184 divided by 5,600, times 100, is 3.3%, comfortably under Postmark's 10%. Complaint rate 6 divided by 5,600, times 100, is 0.107%, just over 0.1%. The blended number hides where the problem is. Split by stream, the transactional mail is fine at 0.03%, and the newsletter is the one over the line at 0.21%.

Separate streams protect the confirmations at the mailbox providers, because they leave from different addresses. They do not protect the account. Postmark's thresholds are written for the account,26 so a messy newsletter can still get the whole account paused, receipts included. Aoife's fix takes an afternoon: she drops the 96 hard bounced addresses, asks the rest whether they still want the newsletter, and keeps those who say yes. The second month's newsletter goes to 1,650 people, with one complaint, 0.06%. Fewer recipients, more readers, and the receipts keep arriving.

Two shortcuts I would not take

The first version of this article suggested two things I correct here. It set the black hole address as the From address on development and staging. That does not stop anything. The black hole works as a recipient: email sent to it disappears. The From address only says who the email is from. Postmark sends from addresses you have verified, a single sender signature or a whole domain,21 and a black hole domain that belongs to Postmark is not yours to verify. The interceptor is what keeps customers safe, because it changes the recipient.

It also pointed development at Postmark with a token read from an environment variable, and then showed a check for Rails.env.production? inside the development file, which can never be true there. In development, do not talk to Postmark at all. letter_opener and previews show you everything, with no token on the laptop to leak.

Before you go live

The list I run through on a new shop, one line per section of this guide.

  • Production and staging tokens in their own encrypted credentials files, key files out of git.
  • One Postmark server per environment, staging Sandbox unless someone needs real inbox rendering.
  • letter_opener in development, :test in test, :postmark in production, staging inheriting from production.
  • The staging interceptor rewriting the recipient and clearing copy and blind copy.
  • A preview for every email a customer can receive, read once by a human.
  • Newsletters on the broadcast stream, everything Solidus sends on the default transactional one.
  • Bounce and spam complaint webhooks, behind basic authentication, feeding a list someone reads.
  • DKIM verified, a custom Return Path, DMARC at p=none for a month, then tightened.
  • Rates per stream checked monthly, against 10% bounces and 0.1% complaints.

None of it is clever. Email should be the most boring part of a shop, and boring takes a little care at the start. For the test side, my article on TDD and BDD in Ruby on Rails shows how I write the tests that check each of these emails is built correctly.

Footnotes

  1. Solidus, Customizing emails, Solidus Guides, 2026, https://guides.solidus.io/customization/customizing-emails

  2. Solidus, order_confirmation_mailer_subscriber.rb, solidusio/solidus on GitHub, 2026, https://github.com/solidusio/solidus/blob/main/core/app/subscribers/spree/order_confirmation_mailer_subscriber.rb

  3. Solidus, order_mailer.rb and base_mailer.rb, solidusio/solidus on GitHub, 2026, https://github.com/solidusio/solidus/blob/main/core/app/mailers/spree/order_mailer.rb

  4. Postmark, API overview: authentication, Postmark Developer Docs, 2026, https://postmarkapp.com/developer/api/overview

  5. Ruby on Rails, Securing Rails Applications: Custom credentials, Rails Guides, 2026, https://guides.rubyonrails.org/security.html#custom-credentials

  6. Ruby on Rails, credentials command usage, rails/rails on GitHub, 2026, https://github.com/rails/rails/blob/main/railties/lib/rails/commands/credentials/USAGE

  7. ActiveCampaign, postmark-rails README, GitHub, 2026, https://github.com/ActiveCampaign/postmark-rails

  8. Postmark, Server sandbox mode, Postmark Developer Docs, 2026, https://postmarkapp.com/developer/user-guide/sandbox-mode/server-sandbox-mode

  9. Ruby on Rails, ActionMailer::Base, Rails API documentation, 2026, https://api.rubyonrails.org/classes/ActionMailer/Base.html

  10. Ryan Bates, letter_opener README, GitHub, 2026, https://github.com/ryanb/letter_opener

  11. Ruby on Rails, Action Mailer Basics, Rails Guides, 2026, https://guides.rubyonrails.org/action_mailer_basics.html

  12. Postmark, Best practices for testing your emails through Postmark, Postmark Support, 2026, https://postmarkapp.com/support/article/1213-best-practices-for-testing-your-emails-through-postmark

  13. Postmark, Generate fake bounces, Postmark Developer Docs, 2026, https://postmarkapp.com/developer/user-guide/sandbox-mode/generate-fake-bounces

  14. Postmark, Can I send bulk emails?, Postmark Support, 2026, https://postmarkapp.com/support/article/can-i-send-bulk-emails

  15. Postmark, How to create and send through message streams, Postmark Support, 2026, https://postmarkapp.com/support/article/1207-how-to-create-and-send-through-message-streams

  16. ActiveCampaign, postmark gem, lib/postmark/message_extensions/mail.rb, GitHub, 2026, https://github.com/ActiveCampaign/postmark-gem/blob/main/lib/postmark/message_extensions/mail.rb

  17. Postmark, What are bounces and spam complaints?, Postmark Support, 2026, https://postmarkapp.com/support/article/what-are-bounces-and-spam-complaints

  18. Postmark, Spam complaint webhook, Postmark Developer Docs, 2026, https://postmarkapp.com/developer/webhooks/spam-complaint-webhook

  19. Postmark, Bounce webhook, Postmark Developer Docs, 2026, https://postmarkapp.com/developer/webhooks/bounce-webhook

  20. Postmark, Webhooks overview, Postmark Developer Docs, 2026, https://postmarkapp.com/developer/webhooks/webhooks-overview

  21. Postmark, How do I set up DKIM for Postmark?, Postmark Support, 2026, https://postmarkapp.com/support/article/1091-how-do-i-set-up-dkim-for-postmark

  22. Postmark, How do I set up SPF for Postmark?, Postmark Support, 2026, https://postmarkapp.com/support/article/1092-how-do-i-set-up-spf-for-postmark

  23. Postmark, DMARC guide, Postmark, 2026, https://postmarkapp.com/guides/dmarc

  24. Postmark, Adding a custom Return-Path domain, Postmark Support, 2026, https://postmarkapp.com/support/article/910-adding-a-custom-return-path-domain

  25. Google, Email sender guidelines, Google Workspace Admin Help, 2026, https://support.google.com/a/answer/81126

  26. Postmark, Does Postmark have a daily send limit?, Postmark Support, 2026, https://postmarkapp.com/support/article/1111-does-postmark-have-a-daily-send-limit

If your shop's emails are a mystery between the checkout and the customer's inbox, bring them to a free workshop and we'll follow one order confirmation through together. The first two weeks of work are on me.