Application notification emails: send fewer, better ones
Updated
Application notifications are the emails your product sends because something happened: a comment, a mention, a task assigned, a report ready. Done badly they train users to filter you out, which then hides the emails that matter.
Decide which events are immediate
| Event | Send | |---|---| | Direct mention or assignment | Immediately | | Approval or access request | Immediately | | Comment on something you own | Immediately, or a short delay to group replies | | Other activity in a shared space | Daily or weekly digest | | Report or export ready | Immediately |
Workflow
- Your application writes a notification row for each event, with a recipient and a type.
- An immediate worker sends urgent types and marks them sent.
- A scheduled job gathers unsent digest-type rows per user, builds one email, sends it, and marks the rows sent.
- Every email links to a preferences page.
Implementation: a digest job (Node.js)
import { Inboxili } from "@inboxili/node";
const inboxili = new Inboxili({ apiKey: process.env.INBOXILI_API_KEY });
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
export async function sendDailyDigests(db) {
const users = await db.users.withUnsentDigestItems();
for (const user of users) {
const items = await db.notifications.unsentForUser(user.id, { limit: 50 });
if (items.length === 0 || !user.prefs.dailyDigest) continue;
const list = items.map((n) => `<li>${esc(n.summary)} <a href="${esc(n.url)}">view</a></li>`).join("");
await db.notifications.markSent(items.map((n) => n.id)); // mark first: a crash must not resend the whole batch
try {
await inboxili.emails.send({
to: user.email,
fromEmail: "notifications@yourdomain.com",
fromName: "Acme",
subject: "{{count}} updates on Acme today",
htmlBody: "<p>Hi {{name}}, here is what happened:</p><ul>{{list}}</ul><p><a href=\"{{prefs}}\">Change your notification settings</a></p>",
textBody: `${items.length} updates on Acme today. Manage settings: https://app.yourdomain.com/settings/notifications`,
templateData: { count: items.length, name: user.firstName, list, prefs: "https://app.yourdomain.com/settings/notifications" },
});
} catch (err) {
await db.notifications.markUnsent(items.map((n) => n.id)); // the request was rejected, so retry tomorrow
console.error("digest failed", user.id, err.status, err.code);
}
await new Promise((r) => setTimeout(r, 600)); // about 100 per minute, under the 120 per minute key limit
}
}
Mark-then-send avoids duplicates after a crash. The catch block puts items back only when the API returned an error, which means nothing was sent. A timeout is different: the email may have gone out, so decide whether a rare duplicate is acceptable before treating connection errors the same way.
Things that go wrong
- One email per event. A busy thread produces dozens of messages. Batch or delay.
- Rate limits. A digest run for thousands of users hits 120 requests per minute. Pace it, as above.
- Unescaped user content in the HTML list.
- No way to turn it off. Link to preferences in every message.
- Sending to deactivated users. Check status before sending.
Delivery events
Use bounced and complained webhooks to switch off notifications for that address. Continuing to mail an address that complains hurts the delivery of your important messages.
Frequently asked questions
- Do notification emails need an unsubscribe link?
- They are generally transactional, but users should be able to turn them off. Link to a notification preferences page, and check the rules in the countries where you send.
- Immediate or digest?
- Send immediately for things a person must act on soon, such as a direct mention or approval request. Digest everything else.
Official SDKs
Send your first notification
Create a workspace, verify a domain, and make your first API call.