Short answer: choose the email API that lets a US/EU SaaS authenticate its custom domain, check a suppression list before sending, and reconcile delivery events by polling without making signup wait. The reliable design is a small queue and event ledger, not a single sent flag.
This is a customer-support workflow: a new account needs a verification link during signup. I run the decision through a one-person SaaS lens. Every hour spent operating mail is an hour not spent shipping a support feature, so I outsource the undifferentiated delivery machinery while keeping the state model in my application.
Start with the delivery contract, not the provider dashboard
First, the sending domain needs ownership and DKIM configuration. Put the DNS records in deployment documentation, assign an owner, and test the authenticated path before launch. DKIM authenticates a message's domain alignment; it does not guarantee inbox placement.
Second, suppression must be a pre-send decision. An address can be syntactically valid and still be in a provider's suppression list after a bounce or opt-out. Record the result as suppressed and stop there. Keep the application's consent record separate from the provider's suppression state.
Third, the signup request should enqueue a durable mail job. The worker can check eligibility, submit the message, and store the provider message ID. The API accepting a request is only an acceptance fact. Delivery, bounce, and complaint evidence arrive later.
The smallest useful adapter has no vendor-shaped business logic:
type MailState = "queued" | "suppressed" | "accepted" | "delivered" | "bounced";
type MailGateway = {
checkSuppression(recipient: string): Promise<boolean>;
send(input: { from: string; to: string; subject: string; text: string; idempotencyKey: string }): Promise<{ messageId: string }>;
pollEvents(cursor: string | null): Promise<{ events: Array<{ id: string; messageId: string; state: MailState }>; nextCursor: string }>;
};
async function processWelcome(
gateway: MailGateway,
signupId: string,
recipient: string,
): Promise<MailState> {
if (await gateway.checkSuppression(recipient)) return "suppressed";
const result = await gateway.send({
from: "hello@mail.example.com",
to: recipient,
subject: "Verify your account",
text: "Your verification link is ready.",
idempotencyKey: `welcome:${signupId}`,
});
await saveAcceptance(signupId, result.messageId);
return "accepted";
}
declare function saveAcceptance(signupId: string, messageId: string): Promise<void>;
The adapter translates the selected API into this interface. The job owns the stable idempotency key. A retry with a new key can create a second welcome message, which is a poor experience and a harder support ticket. A retry with the same key should represent the same application send.
How should DKIM, suppression lists, and event polling fit a no-webhook flow?
Polling changes the data model more than the send call. Store the poll cursor only after applying the returned events. Use a provider event ID as the deduplication key when one exists. If the API gives no such ID, define a stable key from the message ID, event time, and state, then test it against overlapping poll windows.
That interruption matters.
A worker may write an event and stop before advancing its cursor. On restart, the event should be safe to see again. Make event application idempotent, and commit event storage plus cursor advancement together when the database supports that transaction. Otherwise, retain enough information to replay safely. For example, imagine a poll at 10:00 that receives delivered for message m-17. The worker writes that event, then the process is killed before it records the next cursor. The 10:05 poll sees m-17 again, and the database should reject the duplicate event ID while leaving the message state at delivered. If the provider overlaps pages or the scheduler runs twice, the result should be the same. This is a small detail in the adapter and a large part of the reliability story for a support agent trying to explain a missing link.
The catch is latency. Pull-only events are suitable when a verification-link status can wait for the next scheduled pass. They are not suitable when a bounce must immediately trigger another channel, or when a security control needs real-time notification. Pick a webhook-capable event contract for those cases. “No public callback endpoint” is a useful constraint, not a universal virtue.
For a US/EU SaaS, write down the maximum acceptable polling delay, message and event retention, data location requirements, and the support status shown while a job is pending. I'm not sure a feature page can settle regional processing or retention questions; the contract and technical documentation need to answer them.
A one-person SaaS needs a migration test, not a feature leaderboard
I would compare providers with one fixture rather than a feature-count spreadsheet: a verified custom domain, a normal recipient, a suppressed recipient, a duplicate job, a repeated event, and an interrupted poll. The test asks whether the system can explain each outcome. It does not merely ask whether a message appeared in a mailbox.
| Candidate | Useful comparison angle | Boundary to verify |
|---|---|---|
| Resend | A focused API candidate for a narrow sending adapter | Confirm custom-domain, suppression, event, regional, and retention behavior |
| SendGrid | An established mail platform candidate | Verify its event model and a pull-based reconciliation path |
| Postmark | A transactional-mail-focused candidate | Check regional terms, retention, and the status data available to a poller |
| Mailgun | A specialist mail adapter candidate | Test domain setup, suppression behavior, and event deduplication |
| Amazon SES | A cloud mail candidate for teams already using AWS | Check DNS, event routing, reputation controls, and regional choices |
| Self-hosted mail | An option for teams making mail operations a product capability | Deliverability, abuse handling, DKIM rotation, suppression, and reputation become internal work |
The table is a starting point, not a ranking. A specialist service may fit if its event contract matches the workflow. A cloud-native option may fit if existing access controls reduce operational work. Self-hosting is not suitable for a small team that wants its week to go toward customer-support features. Stick with the option whose operational boundary you can explain at 02:00, not the one with the longest checklist.
One more practical filter: force every candidate through the same adapter tests. A provider that has a custom-domain setup but no usable suppression decision is not a complete fit for this flow. A provider with webhooks but no acceptable regional terms is not a fit either. Your mileage may vary because the right retention and region depend on the SaaS's contracts and customer base.
What should the launch test prove for a verification link?
Run a synthetic signup through the complete path. Confirm that the custom domain and DKIM records are ready. Enqueue one welcome job, record the suppression decision, and verify that acceptance stores both the application idempotency key and provider message ID. Restart the worker after submission. There must still be one application send.
Then test the awkward edges. Use a suppressed recipient and confirm that no send is accepted. Poll an overlapping window twice and confirm that it creates one delivery fact. Interrupt the poller between event storage and cursor storage, then run it again. Exercise a 429 response with bounded backoff in the adapter, and keep secrets, full message bodies, and unnecessary recipient data out of logs.
The support workflow is part of delivery reliability. An agent should be able to see whether the link is queued, skipped for suppression, accepted by the mail API, or awaiting a later event. If the team cannot reconstruct that path from application records, the integration is not ready.
Ship weekly. Keep the adapter thin. Spend the saved hours on the explanation a customer needs when a verification link is missing.
Top comments (0)