Notifications

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 access callback;
  • 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:

StatusMeans
pendingWaiting for its availableAt time
sendingClaimed by a worker
sentThe provider accepted it. providerMessageId holds the provider id
failedEvery attempt failed. Retry it from the AdminCP
skippedNothing 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 }:

apps/api/src/lib/email/acme-mail-adapter.ts
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.