Use one AWS region for every step.
Verify a sending domain
In SES, Identities > Create identity > Domain. Use a subdomain like mail.example.com and add the three DKIM CNAME records. Set a custom MAIL FROM domain (bounce.mail.example.com) and add its MX and TXT records. Add DMARC on the root domain: v=DMARC1; p=none;.
The Domains page shows each newsletter's from-domain with its status and the records to add.
Request production access
New accounts are in the sandbox. Ask for production access for marketing email: readers opt in, bounces and complaints are suppressed, every email has one-click unsubscribe.
Configuration set and SNS
Create a configuration set and an SNS topic (signature version 2). In the configuration set, add an event destination for bounces, complaints, deliveries, delivery delays, rejects and rendering failures, publishing to the topic.
IAM user
Allow ses:SendEmail and ses:SendRawEmail on the identity and configuration set, plus ses:GetAccount, ses:ListEmailIdentities and ses:GetEmailIdentity.
Configure the app
SES_REGION=us-east-1
SES_ACCESS_KEY_ID=...
SES_SECRET_ACCESS_KEY=...
SES_CONFIGURATION_SET=oxidt-mail
SES_SNS_TOPIC_ARN=arn:aws:sns:us-east-1:123456789012:oxidt-mail-events
SENDING_ENABLED=trueSubscribe the webhook
Create an HTTPS subscription on the topic to https://<your-app>/api/webhooks/ses with raw message delivery off. The app checks the signature and confirms the subscription itself.
How a send works
Sending picks active, unsuppressed contacts, most engaged first, creates one message per reader and queues one background job per batch. Each job claims its messages before calling SES, so a reader gets an issue at most once even when a job retries. SEND_RATE_PER_SECOND (1–14, default 10) paces every send in the process.
A campaign pauses itself when more than 2% of sent emails bounce or more than 0.08% draw complaints, once at least 100 went out. Resume it from its report.
Warming up a domain
Set a limit when sending to reach the most engaged readers first, then use Send to more on the report once the first step looks healthy.
Sending window
SEND_WINDOW_LIMIT caps emails per SEND_WINDOW_SECONDS (default an hour), counted in the database across every app instance. Set it under your SES 24-hour quota or your relay's hourly limit. When the window is full, the rest of a send waits and resumes as older sends age out.
Soft bounces
A soft bounce (full mailbox, temporary failure) only counts. Three in a row suppress the address like a hard bounce; any delivery resets the count.
Personalization
Emails can use {{name}} and {{attr.key}}, each with a fallback: Hi {{name | there}}. Names and attributes come from CSV imports (a name column, and every other column as an attribute, Company Name becoming {{attr.company_name}}) and from the API. Values are escaped in HTML. Archive pages show the fallbacks.
Readers' rights
The unsubscribe page links to a preference page where a reader can leave or rejoin each of your newsletters, download everything stored about them as JSON, and erase it. Erasing deletes their contacts and email history and leaves only the address on the suppression list, so a later import can't email it again.
Blocked domains
Under Newsletters, list domains whose signups are refused, such as disposable-address services. Subdomains are included. Imports aren't checked: they're your own lists.