DEV Community

Cover image for Per-tenant sender reputation on one SES account
Odinaka Robert Nnamani
Odinaka Robert Nnamani

Posted on

Per-tenant sender reputation on one SES account

If you send email on behalf of other businesses, you are holding a shared fuse. One customer imports a stale list, the bounces pile up, and the account that pauses isn't theirs. It's yours, and with it every other customer who did nothing wrong.

That's the problem I kept circling while building Rootmail, a business email platform where each workspace and sub-tenant sends from its own verified domain with its own DKIM signing key. Separate domains and keys are the visible half of isolation. The half that actually decides whether one customer can hurt another is reputation. For a long time on SES, reputation lived at the account level.

It doesn't have to anymore. SES now has tenants, and this post is about the one mechanism I'd want a stranger to walk away with: give every customer a tenant, make every send name it, and let the pause land on the tenant instead of the account.

What an SES tenant actually is

A tenant is a logical container inside your SES account. You associate resources with it (verified identities, configuration sets, templates), and SES tracks reputation, bounce and complaint rates plus feedback-loop and third-party signals, per tenant.

Two details make it real rather than cosmetic.

First, SES checks the association on every send. If the identity, configuration set or template in the request isn't associated with the tenant you named, the send fails. That's a guardrail you get for free: customer A's code path can't quietly sign mail as customer B's domain.

Second, enforcement is scoped. Each tenant has a reputation policy. Standard (the default) pauses the tenant on high-severity findings, Strict pauses on any finding, and None records findings but never pauses automatically. When a tenant is paused, sends that name it fail. Every other tenant keeps sending.

Setting one up, in TypeScript

My stack is TypeScript, Postgres, Redis and SES, so this is how I'd wire it with @aws-sdk/client-sesv2. Onboarding a customer would look like this:

import {
  SESv2Client,
  CreateTenantCommand,
  CreateTenantResourceAssociationCommand,
} from "@aws-sdk/client-sesv2";

const ses = new SESv2Client({ region: "us-east-1" });
const ACCOUNT = process.env.AWS_ACCOUNT_ID!;
const REGION = "us-east-1";

export async function provisionTenant(tenantName: string, domain: string, configSet: string) {
  await ses.send(new CreateTenantCommand({ TenantName: tenantName }));

  // A tenant needs at least one verified identity and one configuration set before it can send.
  const arns = [
    `arn:aws:ses:${REGION}:${ACCOUNT}:identity/${domain}`,
    `arn:aws:ses:${REGION}:${ACCOUNT}:configuration-set/${configSet}`,
  ];

  for (const ResourceArn of arns) {
    await ses.send(
      new CreateTenantResourceAssociationCommand({ TenantName: tenantName, ResourceArn }),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The identity has to be verified first (the domain, its DKIM records, the usual). Association doesn't verify anything. It only grants the tenant permission to use what you've already verified.

I'd give each customer their own configuration set rather than sharing one. Sharing is allowed, but a dedicated set means event destinations, and anything you read off them, already belong to one tenant without extra tagging.

Every send names its tenant

This is the part that's easy to forget in a codebase that predates tenants. The tenant is a field on the request, not something SES infers from the From address:

import { SendEmailCommand } from "@aws-sdk/client-sesv2";

type OutboundMessage = {
  tenantName: string;
  configSet: string;
  from: string;
  to: string;
  subject: string;
  text: string;
};

export async function sendForTenant(msg: OutboundMessage) {
  return ses.send(
    new SendEmailCommand({
      TenantName: msg.tenantName,
      ConfigurationSetName: msg.configSet,
      FromEmailAddress: msg.from,
      Destination: { ToAddresses: [msg.to] },
      Content: {
        Simple: {
          Subject: { Data: msg.subject },
          Body: { Text: { Data: msg.text } },
        },
      },
    }),
  );
}
Enter fullscreen mode Exit fullscreen mode

If you send over SMTP instead, the same thing travels as an X-SES-TENANT header.

When sending for a tenant you must pass a configuration set associated with it, or use an identity whose default configuration set is associated with it. If you leave both out, you'll find out at send time, not at deploy time. I'd rather have the type system force it, which is why configSet isn't optional above.

Listening for the pause

A tenant that SES pauses is a tenant your app should stop queuing for. Otherwise your workers spend the next hour retrying sends that will all fail. SES publishes tenant events to the EventBridge default bus, so you can route them to an SQS queue or a Lambda.

There's a subtlety in the event shape worth knowing before you write the handler. The Sending Status Disabled detail-type fires for both a pause and a reinstatement. The truth is in detail.data.record.status, which is DISABLED or REINSTATED. And that status applies only to the origin named in detail.data.origin: AWS_SES if SES changed it, CUSTOMER_MANAGED if you did. Either origin being disabled stops the tenant.

type TenantStatusEvent = {
  "detail-type": "Sending Status Enabled" | "Sending Status Disabled";
  resources: string[]; // tenant ARN
  detail: {
    data: {
      origin: "AWS_SES" | "CUSTOMER_MANAGED";
      // ENABLED arrives with "Sending Status Enabled"; this handler only needs to know whether it is DISABLED.
      record: { status: "ENABLED" | "DISABLED" | "REINSTATED"; cause: string };
    };
  };
};

import { db } from "./db"; // a pg Pool

export async function onTenantStatus(evt: TenantStatusEvent) {
  const tenantArn = evt.resources[0];
  const { origin, record } = evt.detail.data;

  // Don't branch on detail-type alone: "Disabled" also covers REINSTATED.
  const blocked = record.status === "DISABLED";

  await db.query(
    `insert into tenant_send_gate (tenant_arn, origin, blocked, cause, updated_at)
     values ($1, $2, $3, $4, now())
     on conflict (tenant_arn, origin)
     do update set blocked = $3, cause = $4, updated_at = now()`,
    [tenantArn, origin, blocked, record.cause],
  );
}
Enter fullscreen mode Exit fullscreen mode

Keeping one row per origin mirrors how SES itself reasons about it. A tenant can send only when no origin is blocked. If you'd rather not reconstruct that yourself, GetReputationEntity returns the combined value as SendingStatusAggregate.

In this wiring, the queue worker checks that gate before it calls SES, right next to the suppression check. In Rootmail, every send already runs a suppression check first, and a provider event goes into the message's append-only lifecycle log as evidence of what the system observed, not as proof that someone read anything. A tenant pause is one more piece of that evidence.

Suppression is shared unless you say otherwise

This one surprised me. By default, every tenant shares the account-level suppression list. If a recipient hard-bounces on customer A's mail, that address is suppressed for customer B too, even though B may have a perfectly good relationship with them.

SES supports tenant-level suppression lists. You set the scope to TENANT and choose whether bounces, complaints or both are tracked. Then one tenant's bounce stays that tenant's problem. Whether that's what you want depends on your product, but it should be a decision, not a default you discover later.

What isolation doesn't buy you

Here are the edges, all from the SES docs:

  • The account still carries the sum. Tenant tracking isolates reputation, but AWS says plainly that combined tenant activity still affects your overall account standing. A badly behaved tenant can still put the account at risk. Tenants contain the blast radius; they don't remove your responsibility to watch it.
  • Findings need volume. Some reputation findings only trigger after a minimum representative volume, and metrics are calculated over rolling windows from about 24 hours to 7 days. A tenant that sends a little won't show a finding quickly.
  • Tenants are regional and flat. They don't replicate across regions, they don't nest, and they can't span accounts.
  • There's a per-tenant charge. It's on the SES pricing page. Factor it in before mapping every end user to a tenant.

And the edge I live with every day: none of this matters to outbound mail until your account leaves the SES sandbox. In the sandbox you can send only to verified addresses and the mailbox simulator. Rootmail's SES account is still in the sandbox. My production access request was denied, and I'm reapplying with a clean sending history. So the beta is honest about that: testers send only to their own AWS-confirmed inboxes and the simulator, capped at 12 sends a day per org. The tenant wiring works the same either way; what's missing is real recipients.

Where this leaves you

If you're building something multi-tenant on SES (a booking tool, a CRM, a clinic system, an agency sending for clients), the mechanism is small: one tenant per customer, a configuration set associated with it, TenantName on every send, an EventBridge rule that closes your own gate when SES closes theirs, and a deliberate choice about suppression scope.

Rootmail is built around that problem: workspaces and sub-tenants with their own verified domains, signing keys and isolated reputation, behind one API. The closed beta has 8 seats, with the sandbox limits above stated plainly. The code in this post is how I'd wire the SES side, not a copy of Rootmail's. If you send for other people and want to try it, testers confirm Amazon's verification email within 24 hours, and places are open at https://rootmail.io/beta?ref=devto.

Top comments (0)