Sending automated alerts and password resets from the same address was quietly wrecking our deliverability. Here's what we changed, including the parts we got wrong first.
canonical_url: https://production-notes.hashnode.dev/one-sender-two-kinds-of-mail?utm_source=hashnode&utm_medium=feed
Sending automated alerts and password resets from the same address was quietly wrecking our deliverability. Here's what we changed, including the parts we got wrong first.
What follows is what was actually going on, and what we changed. Some of it we got right the first time. A fair bit of it we didn't.
Three bugs that were one bug
The reports trickled in over a few weeks, and because they arrived separately they each looked like their own small problem.
- A dispatcher never received their login code.
- A carrier said the rate confirmation "never arrived" — it was in Junk.
- An invoice landed in spam for one customer but inbox for another.
We chased each one individually. Was it the template? A bad From address? Some specific corporate mail server being aggressive?
It was none of those. Every one of those emails had sent successfully. SendGrid accepted them and reported them delivered, and then Gmail filed them under Junk without telling anybody. Our provider dashboard stayed green the whole time.
What took us embarrassingly long to work out is that there was nothing wrong with any individual email. The problem was that we had one sender reputation and we were spending it on the wrong mail.
What was actually wrong
We run a logistics platform that sends a lot of email. It divides cleanly in two, though we weren't treating it that way.
Mail a human is waiting for, right now: login codes, password resets, invoices and settlements, rate confirmations, account invitations.
Mail a machine generated on a schedule: maintenance due and overdue, compliance documents expiring, low fuel and low DEF, speeding alerts, reefer temperature out of range, load status updates, marketplace bid blasts to hundreds of carriers, daily ticket digests.
Every one of these went out the same way — sendMail({ to, subject, html }). Same sender domain, same reputation pool, no distinguishing metadata whatsoever.
The thing we'd missed is that mailbox providers don't really judge emails, they judge senders. From Gmail's side, our sender was an address pushing out a growing volume of automated notifications that recipients mostly didn't open, with no way to unsubscribe from any of it. That profile looks a lot like a spammer, and Gmail treated it accordingly.
So it throttled us. Not the alerts specifically — the sender. Which meant the password reset our customer was waiting on got throttled too, for reasons that had nothing to do with password resets.
By default your bulk mail and your transactional mail share one reputation. The bulk mail earns the complaints and the transactional mail pays for them.
Before. Every kind of mail left through one untagged sender, so one reputation covered all of it. The complaints came from the automated alerts on the left; the throttling they earned applied to the password reset at the bottom just the same.
There was a hard deadline attached, too. Gmail and Yahoo's bulk sender requirements mandate one-click unsubscribe and List-Unsubscribe headers on bulk mail, with enforced complaint-rate thresholds. We weren't just degrading — we were non-compliant, and heading toward being blocked outright rather than merely filtered.
The fix, in one sentence
Every email declares what kind of email it is, and that declaration decides whether it can be unsubscribed from.
Which sounds too simple to take a sprint. The idea is easy; the work was getting it applied consistently across a few hundred send sites, and making sure it stayed applied after we stopped paying attention.
Our first attempt didn't do that. We added an optional category parameter to the mail helper and updated the call sites we could find by grepping. That got maybe eighty percent of them. The rest kept sending uncategorized, and because the parameter was optional nothing complained. We only noticed when a report digest turned up in a suppression list it had no business being in. That's what pushed us toward making the type system do the work instead of us.
A taxonomy, not a boolean
The obvious move is a boolean: isTransactional: true. We argued about this for a while and ended up not doing it.
The problem with a boolean is that it gives you exactly one unsubscribe group covering all bulk mail. A fleet manager who's tired of reefer temperature alerts would have to unsubscribe from everything, marketplace bids included, and those bids are the emails he actually wants. When you give people that choice they usually pick one of two bad options: put up with the noise until they eventually hit "Report Spam" (which is the thing you were trying to avoid), or opt out wholesale and miss something that mattered.
So we went with eight categories instead. One of them behaves differently from the other seven.
Illustrative — the shape, not our source
const EMAIL_CATEGORY = {
// User-triggered, 1:1. No unsubscribe group. Never suppressible.
TRANSACTIONAL: "transactional",
// Everything below is machine-generated → gets an unsubscribe group.
EQUIPMENT_ALERTS: "equipment_alerts",
SENSOR_ALERTS: "sensor_alerts",
ORDER_UPDATES: "order_updates",
MARKETPLACE: "marketplace",
BILLING_NOTICES: "billing_notices", // reminders, NOT the invoice
REPORTS_DIGESTS: "reports_digests",
PRODUCT_UPDATES: "product_updates",
} as const;
The seven non-transactional categories each map to a provider-side unsubscribe group. TRANSACTIONAL maps to nothing, by design. That distinction is the entire architecture.
What each class of mail carries
| Class | Category tag | Unsub group | Suppressible |
|---|---|---|---|
| Transactional | yes | never | never |
| Everything else | yes | yes | per-category |
After. The category attached at the send site decides everything downstream. A complaint about reefer alerts now becomes an opt-out from **that one group* — it never reaches the transactional lane below the dotted line.*
The comment on BILLING_NOTICES is doing more work than it looks. A reminder that your trial ends next week is marketing, so you should be able to turn it off. The invoice itself is a financial document, so you shouldn't. Drawing that line correctly for each category took us longer than writing any of the code, and it isn't really an engineering question.
Make forgetting a compile error
A taxonomy that relies on developers remembering to use it will be wrong within two sprints. Someone adds a notification type, doesn't pass a category, and it silently defaults to uncategorized — back to the original bug, one email at a time.
We closed that with an exhaustive Record.
Illustrative — the map that cannot be left incomplete
// Keyed by EVERY notification type. Adding one without deciding its
// category is a COMPILE ERROR, not a runtime surprise.
const CATEGORY_BY_NOTIFICATION: Record<NotificationType, EmailCategory> = {
OrderAssigned: EMAIL_CATEGORY.ORDER_UPDATES,
ServiceDueSoon: EMAIL_CATEGORY.EQUIPMENT_ALERTS,
SensorOutOfRange: EMAIL_CATEGORY.SENSOR_ALERTS,
PasswordReset: EMAIL_CATEGORY.TRANSACTIONAL,
// ... every notification type in the system
};
Because it's a total Record over the notification-type union rather than a Partial, you can't add a notification type without assigning a category. The build fails. So the decision gets made by whoever is adding the notification, at the point where they still have all the context about what it is, instead of surfacing months later as a support ticket.
If there's one piece of this worth copying, it's that. We'd tried the wiki-page version and it lasted about three weeks.
Keep the mail package dumb
Our shared mail package is a thin wrapper over the provider, used by several services. It deliberately has no database access, and we wanted to keep it that way. But turning a category into an unsubscribe-group ID needs a lookup. So we split the job three ways:
- A shared package owns the taxonomy — pure types and constants, no I/O.
- One service owns ID resolution — async, database-backed, cached.
- The mail package accepts an already-resolved number and nothing else.
Illustrative — the mail layer stays dumb
// The transport takes an already-resolved number and nothing more.
// No database, no category lookup, no policy decisions.
if (typeof options.groupId === "number" && options.groupId > 0) {
message.asm = { groupId: options.groupId };
}
Call sites resolve the ID asynchronously, then pass a plain number in. The mail package stays DB-agnostic and trivially testable.
One deliberate choice about where the IDs live: not environment variables. Seven env vars across every host means a DevOps ticket every time a group changes. Instead they live in a single application-settings row, editable at runtime through an admin screen and cached briefly.
And critically — the resolver fails safe:
The invariant worth writing down
Transactional mail must NEVER carry an unsubscribe group.
Group resolution returns nothing for transactional mail, and nothing
for any category not yet configured — fail-safe: the email still
sends, just without a group-unsubscribe link.
If the config is missing or malformed, the mail still goes out, just without an unsubscribe link on it. That's a deliberate trade. Missing an unsubscribe link is a compliance problem we can fix on Monday. Failing to deliver a password reset is an outage. Given a choice about which way to fail, we'd rather fail toward the one that doesn't lock people out of their accounts.
The bug the seed file caused
This next one is the failure I think about most, because there was nothing in the diff for a reviewer to catch.
The code was right, the types were exhaustive, the tests passed. And for about a week after we deployed, every single alert still went out with no unsubscribe link on it.
The settings row holding the category-to-group-ID map had never been created in the deployed environments. We'd added it locally while developing and never thought about it again. So group resolution came back empty for every category, took the fail-safe path exactly as written, and quietly sent everything without a group. Working as designed and completely wrong at the same time.
The fail-safe did exactly what we built it to do, and in doing so it hid the fact that the feature wasn't running at all.
The fix was an idempotent, create-only seed that runs on every deploy: it establishes the seven mappings once if they're absent, and deliberately does not overwrite them if they already exist.
The bigger lesson was about the fail-safe itself. Falling back gracefully is the right behaviour, but a fallback nobody can see isn't graceful, it's just invisible. We added a warning when resolution comes back empty. It fires once per category per process rather than once per email, which we learned the hard way after a marketplace blast resolved the group per recipient and produced several thousand identical log lines in about a minute.
The create-only part of the seed matters too, and it's easy to get backwards. If the seed overwrote existing values on every deploy, any change an admin made through the settings screen would silently revert the next time we shipped. Establishing the value once and then leaving it alone is what you want.
Give users a real preferences page
SendGrid hosts an unsubscribe preference page for free, and we shipped with it. But it has two problems: it's only reachable from an email, and it's SendGrid-branded. Users expect to manage notifications inside the product, and support staff want to fix a recipient's subscriptions without opening a third-party console.
So we built an in-app preferences screen. The single most important decision was this one:
Source of truth: mirror SendGrid. No new DB schema. The screen reads and writes SendGrid suppression data directly, so it can never drift from the email unsubscribe links.
The tempting version is a local preferences table. Reads are faster and you're not dependent on a third party being up. It also guarantees you a consistency problem: someone clicks unsubscribe in an email, so the provider knows about it, then opens your settings page, which doesn't, and sees themselves still subscribed. Now you're writing reconciliation logic and webhook handlers and deciding which side wins a conflict, permanently, to save a couple hundred milliseconds on a page almost nobody visits.
Reading through to the provider avoids all of that. The screen ends up being a fairly thin client over three calls: read the suppressions for an address, add one, remove one. Writes only send the categories that actually changed, so flipping a single toggle makes one call rather than seven.
The identity detail that matters
One rule here mattered more than the rest: the endpoint for managing your own preferences doesn't take an email address at all. It works out who you are from your session and ignores anything the client sends it.
Had we let it accept an address from the request body, anyone could have unsubscribed anyone from anything. It's the sort of thing you can guard with a validation check, but it's safer to not have the parameter in the first place, so we didn't add one.
The trap in the admin endpoint
Support staff need to be able to fix a recipient's preferences on their behalf, which means an admin version of the endpoint that does take an email address as input. This is where multi-tenant applications tend to get caught out, and it's worth walking through the shape of it because the mistake is easy to make and hard to spot in review.
Sketch the naive version and it looks completely reasonable. The route is permission-gated. The caller is a legitimate admin with a real need. The provider call is the same one the self-service path already uses. Every individual piece checks out.
The problem is what sits between them. Your provider's suppression list is global to your account, with no notion of tenants in it at all. Your application is multi-tenant. If nothing in the middle enforces that boundary, an admin at one tenant can pass in an address belonging to another tenant's user and the provider will happily serve it. Classic IDOR, arrived at without anyone writing anything that looks wrong.
The missing boundary. The provider's suppression list is global; the application is multi-tenant. Nothing in the stack reconciles those two facts for you, so the check is yours to write. Note **404 rather than 403* on the reject path: a 403 would confirm the address exists somewhere and turn the endpoint into an enumeration oracle.*
The shape of the fix is to resolve the supplied address against your own user table first, scoped to the caller's tenant, and only hand it to the provider once it comes back. Anything that doesn't resolve gets rejected before the external call happens.
The status code on that rejection matters more than it looks. A 403 tells the caller the address exists somewhere in the system but isn't theirs, which is enough to enumerate other tenants' users one guess at a time. A 404 returns the same answer whether the address is absent entirely or simply out of scope, so there's nothing to learn from probing it.
A global third-party API sitting inside a multi-tenant app is a tenant boundary, and nothing in your framework knows that.
This generalises well past email. SendGrid, Stripe, Twilio and most of their peers have no concept of your tenants, so any call that forwards a user-supplied identifier is a place where the scoping is yours to do. It's worth grepping for that pattern across your own codebase; the email endpoint is rarely the only instance.
Stop poisoning your own reputation with test data
A small one with an outsized effect. Our end-to-end suite ran against a staging environment and generated signups with throwaway addresses at example.com. Those reached the provider, hard-bounced (nobody owns example.com), and hard bounces are one of the strongest negative reputation signals there is.
Which meant that on every CI run we were demonstrating to Gmail that our sender emails people who don't exist.
We put the filter inside the mail helper itself rather than in the test setup or behind an environment check, for reasons I'll come back to below.
Illustrative — the guard belongs at the exit
it("never hands a test address to the provider", async () => {
await sendMail({ to: "qa@example.com", subject: "Test" });
expect(transport.send).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
"Blocked test recipient",
expect.objectContaining({ blocked: ["qa@example.com"] }),
);
});
Batch sends drop the blocked recipients and deliver to everyone else, so one test address sitting in a marketplace blast doesn't take the whole batch down with it.
The reason it lives in the mail helper is that the test suite isn't the only way these addresses get in. They arrive through manual QA on staging, through imported CSVs, and through demo accounts somebody set up two years ago and forgot about. Filtering at the point of send catches all of those; filtering in the test config catches one.
What we'd tell you to do first
If you send alerts and password resets from the same sender, you probably have some version of this already; you just haven't had the support call yet. Roughly in order of how much they bought us:
- Split transactional from bulk before anything else. Most of the benefit is here, and even a rough split helps. You can refine the categories later.
- Make the category a required argument, not an optional one. We learned this by shipping the optional version first and watching a fifth of our send sites quietly ignore it.
-
Get
List-Unsubscribeonto your bulk mail. Gmail and Yahoo require it for bulk senders, and an unsubscribe click costs you one recipient where a spam complaint costs you a bit of your sender reputation with everybody. - Read subscription state from your provider rather than keeping your own copy. If you keep a copy you will eventually have to explain which version is correct.
-
Filter test addresses in the mail layer. Hard bounces to
example.comcost you reputation and buy you nothing. - Go looking for other places you hand a user-supplied identifier to a third party. Anywhere the external API is global and your app isn't, the scoping is yours to write.
The part that stuck with me
The thing I'd keep out of all of this isn't any of the code. It's the comment we left at the top of the taxonomy file, explaining why the file exists at all — that mixing bulk alerts with transactional mail on one reputation drags the whole account toward spam.
Sometime next year somebody will add a notification type, hit the compile error, and open that file to find out which category they're meant to pass. The type system will have forced them to make a decision. Whether they make a good one depends on whether they understand that choosing an alert category over the transactional one is the difference between a user muting a notification and a user not being able to get back into their account.
The compiler can make you decide. It can't tell you what the right answer is. That part still has to be written down for the next person.
We happened to be on SendGrid, but none of this is specific to it. Any provider with unsubscribe groups and a suppression API will let you do the same thing under different names.



Top comments (0)