Short answer: keep template ownership in the application team, keep delivery evidence in a local ledger, and let a scheduled Node.js worker turn a bounced message into a scoped suppression decision. A dashboard should explain what happened; it should not be the place where a browser click decides whether a patient notification can be sent.
That split is the useful starting point for a small healthtech SaaS. The team owns the wording, consent context, recipient policy, and audit trail. The mail transport owns the delivery attempt and its events. I would ship this boundary before building charts. Revenue per hour matters, and a fancy deliverability dashboard doesn't fix an address that the product keeps sending to.
Keep the boundary boring.
The template owner sets the safety boundary
An appointment reminder and a marketing email may use the same mailbox, but they do not have the same risk. A typo in a lab-result address should not be handled like a temporary receiving-server delay. A shared template catalog also makes it too easy for a marketing edit to change a clinical notification without a review trail.
For each message, store a stable template key and version beside the tenant, notification purpose, recipient reference, and provider message ID. The ID is the join key for later events. An address is not. One address can receive two reminders in the same minute, and a support agent needs to know which one bounced.
Template ownership should be explicit:
- Product or engineering owns appointment, password-reset, and lab-result templates.
- A content editor may propose copy, but a change needs a version, reviewer, and effective time.
- The send record stores the version used, so a later template edit cannot rewrite history.
This is a small amount of ceremony. It is cheaper than explaining to a clinic why the dashboard says sent while the notification used an unreviewed template.
Google's sender guidance also puts authentication, consent, message quality, and complaint handling in the deliverability conversation. A local status page cannot compensate for weak sender identity or poor recipient hygiene.
How should a Node.js SaaS dashboard read sent, delivered, and bounced events?
The smallest useful implementation has three layers: a transport adapter, a normalizer, and a database upsert. The web request reads the local receipt table. A worker polls the documented event source on a schedule, using the message IDs from the send ledger.
Here is the domain part. It has no vendor assumptions, so replacing the transport does not change suppression policy or template ownership.
type ReceiptState = "sent" | "delivered" | "bounced" | "unknown";
type DeliveryEvent = {
messageId: string;
type: string;
occurredAt: string;
};
type Receipt = {
messageId: string;
state: ReceiptState;
eventAt: string | null;
};
type EventReader = (messageIds: string[]) => Promise<DeliveryEvent[]>;
function normalizeState(type: string): ReceiptState {
switch (type.toLowerCase()) {
case "sent":
case "accepted":
return "sent";
case "delivered":
return "delivered";
case "bounce":
case "bounced":
return "bounced";
default:
return "unknown";
}
}
function latestByMessageId(events: DeliveryEvent[]): Receipt[] {
const latest = new Map<string, Receipt>();
for (const event of events) {
const next: Receipt = {
messageId: event.messageId,
state: normalizeState(event.type),
eventAt: event.occurredAt,
};
const previous = latest.get(event.messageId);
if (!previous || (previous.eventAt ?? "") <= event.occurredAt) {
latest.set(event.messageId, next);
}
}
return [...latest.values()];
}
export async function pollReceipts(
messageIds: string[],
readEvents: EventReader,
): Promise<Receipt[]> {
if (messageIds.length === 0) return [];
return latestByMessageId(await readEvents(messageIds));
}
The adapter behind readEvents should own authentication, pagination, rate-limit backoff, and response validation. It should return an empty event set when a valid poll finds no matching event, not manufacture delivered. It should also record the poll attempt. A worker retry must be safe, so the database key should include the tenant and message ID, while the event timestamp decides which observation is newer.
One mistake is easy to make here: treating a successful send request as delivery. sent means the sending system accepted the request. delivered means a delivery event was observed. Neither proves that a person opened or understood the message. I don't treat an empty 09:01 poll as a bounce — absence is not a recipient verdict. Your mileage may vary on the polling interval, because the right delay depends on how quickly the product needs to surface an operational signal.
Turn a bounce into a scoped suppression decision
The dashboard needs a state machine, even if the first version is only one table and one worker. Keep the evidence and the action separate.
| Observed event | Receipt shown | Automatic action |
|---|---|---|
| send accepted | sent | Wait for a later event |
| delivery observed | delivered | Keep the recipient eligible |
| permanent recipient failure | bounced | Suppress the address for the affected purpose |
| no event yet or unknown event | unknown | Show poll age; do not suppress from absence |
“Bounced” is not a universal ban. In a healthtech product, suppression might apply to appointment reminders for one tenant while leaving an authenticated in-app notification available. Store the channel, purpose, reason, source event time, and actor that created the suppression. Let support review the decision with an audit trail.
Temporary failures need their own path when the event source provides that distinction. Do not turn a transient delay into an invalid-recipient record. If the source gives only a broad bounce classification, keep the raw event access-controlled and make the automatic rule conservative.
The example is deliberately ordinary. A reminder is accepted at 09:00. The 09:01 poll sees nothing. At 09:04, a bounce arrives. The ledger should retain the send time, both poll attempts, the event time, the normalized state, and the suppression write. Re-reading that bounce may update last_seen_at; it must not create a second suppression record or send another reminder. That history matters when a support agent asks why the dashboard changed after the first poll, when a tenant changes the recipient reference, or when a worker retries the same page after a process restart. The old observation should remain evidence, while the current normalized state remains a derived read model. A correction can add a new audit entry, but it should not rewrite the template version or message ID that were used at send time. This is the part that takes more care than the chart: several timestamps describe different facts, and collapsing them into one updated_at value makes an otherwise understandable workflow hard to review.
What changes when template ownership and polling scale up?
For a one-person SaaS, a scheduled worker and a read-only dashboard are enough to start. Add a last_polled_at value to every account or partition and show stale data plainly. This keeps page latency independent of the mail transport and gives the operator a useful failure signal.
At higher volume, process event pages in batches and advance a cursor only after the related upserts commit. Measure poll age, event lag, unknown event types, suppression writes, and the number of messages with no matching event. Keep the raw event payload under the product's access and retention rules; email metadata can still be sensitive.
The catch is that polling is not suitable for an action that must run within seconds of a bounce. Use a documented push transport when the workflow needs that latency, and verify its retry, authentication, and duplicate-delivery behavior. Stick with polling for support triage and dashboards where observation delay is acceptable.
Template ownership has a similar limit. A shared, user-editable template system is a poor fit for regulated notifications unless review and versioning are built in. If the product cannot provide that audit trail yet, keep high-impact templates in a small, reviewed catalog and use a separate editor for lower-risk messages.
That is enough for the first release. I've kept the transport plumbing outside the policy layer because it changes independently; the suppression rule, message ID, template version, and compliance evidence should stay in the code you own. Ship weekly. Outsource the undifferentiated transport plumbing, but keep the decision record close to the application.
Top comments (0)