Email and digests
How VitNode sends notification email - immediate messages and daily or weekly digests in each user's time zone, with delivery records, retries, idempotency keys and send-time checks.
Notification email reuses your configured email adapter and
the queue. There is nothing to set up beyond
email: true on a type. Each user picks one mode per type: none,
immediate, daily or weekly.
Immediate email
Fan-out creates one delivery per event and user, keyed
immediate:{eventId}:{userId}, and queues the notifications-email task. That
task sends due deliveries in batches, a few at a time, so a slow provider only
slows email - never the inbox.
Each email is rendered in the user's language with a link to the item and a
Manage notification preferences link to /settings/notifications.
Daily and weekly digests
Events for digest users wait as pending email receipts. Every 5 minutes the
notifications-schedule cron plans the digests whose period has ended:
- Periods are local. A daily digest covers one calendar day in the user's time zone and goes out at 08:00. A weekly one covers seven days and goes out on Monday at 08:00. The schedule is the same for everyone - only the time zone is personal.
- Time zone fallback. The time zone the member set on
/settings, then the time zone of their language, then UTC. - DST-safe. Periods are contiguous. On the night clocks change, a "day" is 23 or 25 hours long - no day is skipped or sent twice.
- Claimed once. A digest's key is the user, mode and local date of its
period, such as
daily:42:2026-10-04. Planning it again finds the key and does nothing. - Never re-included. Each event joins exactly one digest. An event that arrives after a period ended waits for the next one.
- Empty digests are skipped. No email says "nothing happened".
A digest lists up to 25 notifications, newest first, and says how many more are waiting.
What is checked at send time
Nothing is decided from the state at fan-out time. Right before any email or digest goes out, core checks each notification again:
- the user's current email mode for the type;
- the type's installation policy;
- the type's
accesscallback; - whether the user already read it.
Only unread notifications are emailed
Digests and delayed emails include only notifications that are still unread. If the user read or archived an item in the inbox first, it is dropped from the email. A digest left with nothing in it is skipped.
If the user switched modes since the event arrived, the event is re-planned for
the new mode, or dropped for none. A user who takes a type by email only has
no inbox item to read, so their email always goes out.
Delivery records
Every send - immediate, digest or test - is a row in
core_notification_deliveries:
| Status | Means |
|---|---|
pending | Waiting for its availableAt time |
sending | Claimed by a worker |
sent | The provider accepted it. providerMessageId holds the provider id |
failed | Every attempt failed. Retry it from the AdminCP |
skipped | Nothing to send. skipReason says why, e.g. empty, email_disabled or user_missing |
Retries
A failed attempt goes back to pending with exponential backoff: 10 seconds,
then 20, 40 and 80. After the fifth attempt it is failed. The stored error is
sanitized: email addresses, tokens and keys are redacted, and it is cut to 500
characters.
Retrying with POST /deliveries/{id}/retry reuses
the same row, the same idempotency key and the same notifications - a retry can
never turn into a second, different email.
Idempotency and duplicates
Every send passes a stable key to your adapter as idempotencyKey:
vitnode-notification- plus the delivery key. Every attempt of one delivery
uses the same key.
That matters for one unavoidable gap: the provider accepted the email, then the
worker crashed before recording it. The delivery is still sending, and after
15 minutes the scheduler puts it back to pending.
- Resend forwards the key, and drops a second send with the same key for 24 hours. No duplicate.
- SMTP (Nodemailer) has no idempotency. The retry can arrive twice.
So notification email is at least once. Exactly once is only as good as your provider's deduplication.
Support idempotency in a custom adapter
sendEmail receives an optional idempotencyKey and may return { id }:
import type { EmailApiPlugin } from '@vitnode/core/api/models/email'
export const AcmeMailAdapter = ({
apiKey,
from,
}: {
apiKey: string
from: string
}): EmailApiPlugin => ({
sendEmail: async ({ to, subject, html, text, idempotencyKey }) => {
const res = await fetch('https://api.acmemail.example/v1/send', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
},
body: JSON.stringify({ from, to, subject, html, text }),
})
if (!res.ok) throw new Error(`Acme Mail error: ${res.status}`)
const { id } = (await res.json()) as { id: string }
return { id }
},
})Forward the key in whatever field your provider documents for deduplication, and ignore it if the provider has none. Throw on failure so the delivery is retried, and return the provider's message id so it shows in the AdminCP. Returning nothing is fine too. See Custom email adapter.
Related
Preferences
How VitNode decides which notifications each user receives in the notification list, as push and by email - installation policy, mandatory types, user choices and time zone.
Queue and scale
How VitNode delivers notifications to large audiences - queue tasks, cron jobs, batched fan-out with saved cursors, crash recovery, lock ordering and measured numbers for 1,000 recipients.