Skip to content

Blog

Per-customer sending domains on SES for a Laravel SaaS

One Amazon SES account, many customers, each sending from its own domain: how getodden/ses-tenants keeps their reputations apart, warms new domains up and pauses one customer without pausing everyone.

Darren, building Odden ·

If your SaaS sends email for its customers, you will be asked, sooner or later, to send from their domain: hello@acme.com, not acme@yourapp.com. On Amazon SES that means one account, many customers, each with its own verified identity. It also means one customer's bad week can become everyone's: SES judges the account, not the customer.

I ran into this building Odden, a CRM, and I could not find a Laravel package for it, so I wrote one and open-sourced it: getodden/ses-tenants. It is MIT, on Packagist, for Laravel 13.

What the problem actually is

Adding a customer's domain is the easy part: create an SES identity with Easy DKIM and a custom MAIL FROM, show the customer the DNS records, poll until SES says verified. The package does that, and builds the records (the DKIM CNAMEs, the bounce domain's MX and SPF, a suggested DMARC) for you.

The hard parts come after the domain is verified:

  1. A customer must not be able to send as someone else. The transport checks every address a message could use (From, Sender, Return-Path and the envelope) against that customer's verified domains, and refuses otherwise. The SES tenant name is set by the package and stripped before delivery, so a message cannot pick its own tenant or leak it.
  2. A new domain has no reputation, and the account's reputation is shared. A fresh domain that sends 50,000 messages on day one is how an account gets put under review.
  3. One customer with a dirty list must not take the others down. You want to notice before SES does, and stop that customer alone.

Warm-up, and what the package does and doesn't decide

A new sending domain gets a daily ceiling for bulk mail that steps up from its first day of sending:

'steps' => [200, 500, 1000, 2000, 5000, 10000, 20000],

The package reports the ceiling (Warmup::ceiling($domain)) and counts what was sent. What to do with mail over it is yours: Odden holds the rest of a campaign until tomorrow. One-to-one mail, a receipt or a password reset, is never held. A domain can be started further along or skipped, for a customer who brings a good sending history.

Pausing one customer without pausing everyone

Each owner's hard-bounce and complaint rates are counted over a rolling seven days, once it has sent enough for a rate to mean anything. The defaults sit well under the levels at which SES itself reviews an account:

Warn Pause
Hard bounces 2% 4%
Complaints 0.05% 0.1%
While a domain is warming up 1% / 0.03% 2% / 0.05%

When an owner crosses the pause line, the package pauses it in two places: in your database, so the send gate refuses it, and its SES tenant, so nothing can send around the package. You hear about it through a Laravel event (SendingPaused) and decide what to tell the customer. Only you lift a pause, after they have cleaned their list, and the rates count from that day on so the old numbers don't pause them again at once. If SES pauses a tenant itself, the package follows it, in both directions.

A pause is not a surprise because the warning comes first: ReputationWarning fires well before the pause line, once a window.

Events, not opinions

The package does not send emails, write audit logs or apply suppressions. It fires events (DomainChanged, DomainStoppedWorking, SesEventReceived, SendingPaused, SendingResumed, ReputationWarning, MessagesSent), and you decide. Your plans and allowances go in as SendLimit classes the gate asks before every send. Odden's are a paid-workspace check and a monthly allowance; yours will differ.

SES delivery events come in over SNS, and the endpoint proves each one before reading it: a certificate host pinned to the topic's region, a chain to the Amazon Trust Services or DigiCert roots, your topic only, a recent timestamp. SnsVerifier is usable on its own if all you need is that.

Using it

composer require getodden/ses-tenants

class Team extends Model implements SesOwner
{
    public function sesTenantName(): string { return "team-{$this->id}"; }
    public function sesIsRisky(): bool { return $this->onTrial(); }
}

$domain = app(SendingDomains::class)->add($team, 'acme.com');   // identity + tenant + DNS records

The database layer (domains, usage, warm-up, reputation) is opt-in by config; without it you get the identity, transport, DNS and SNS pieces to build on.

How much to trust it

Some background, and then the caveats. I built Odden because I wanted HubSpot's functionality without HubSpot's per-seat cost, and I built it from what I learned running CaskStack. I now run the marketing for both Odden and CaskStack from it, and this package was extracted from the hosted email in Odden Cloud, which is still in development (there is an early-access list).

So be sceptical in proportion. The code runs in production and staging, and the SNS verification, certificate chain included, has handled live events. But no customer has added a sending domain in production yet, and the persistence layer is tested against a fake SES on SQLite, PostgreSQL and MariaDB, not a real account. It is pre-1.0 and a minor version may change the API. If you run an SES multi-tenant SaaS, I would like to hear what breaks.

It stays MIT. Paid features, if there are any, will live in the products built on top of it, never behind a license check in the package.

github.com/getodden/ses-tenants

All posts · Our open-source packages