Customers are not getting emails, SMS or push

Using the notification console to tell a dead worker from a dead mailer — the queue counts, the health components, the test senders, and the traps that fail silently.

9 min readUpdated 6 August 2026notifications, email, sms, push, queue, runbook

Notification delivery is what a customer sees after a withdrawal decision, a KYC decision, a password reset or a one-time code. A silent mailer does not look like an outage — it looks like every decision you make turning into a support ticket, and it can run for days before anyone connects the two.

The console that answers this is System → Communication Tools → Notification Service (/admin/system/notification). The active tab is in the URL as ?tab=, so a link to one is shareable.

The Test tab sends real messages through the real transport, on demand, to your own account. One click separates "the transport is broken" from "the platform never tried" — and those two have completely different fixes.

Every other diagnosis on this page is faster once you know which half you are in.

The seven tabs, and the four that matter in an incident

Tab Answers Endpoint
Overview Is the service up, which channels registered, what has it sent GET /api/admin/system/notification
Health Redis, the registered channels, the queue and the cache hit rate GET .../notification/health
Test Does each channel actually deliver four POST .../notification/test/*
Queue What is waiting, and how much has failed GET .../queue/stats, .../queue/items
Metrics Sent, failed and success rate per channel GET .../notification/metrics
PWA The installable-app manifest — not a delivery surface GET/PUT /api/admin/system/pwa
Settings What the service registered at boot GET .../notification/settings

Overview polls every 30 seconds; the Queue tab refreshes every 5.

Every endpoint behind the six notification tabs gates on the single key access.notification.settings. The PWA tab is the exception, and it is not really part of this service: it reads and writes /api/admin/system/pwa (view.settings and edit.settings), uploads screenshots through /api/admin/system/pwa/screenshot and app icons through /api/admin/system/settings/logo (both edit.settings). A role that can work this console can therefore be unable to open that one tab, and vice versa.

Reading the queue

The Queue tab shows five counts, from GET .../queue/stats:

Count Means
waiting Queued, nothing has picked it up yet
active (shown as Processing) A worker is sending it right now
completed Delivered
failed The transport rejected it after every retry
delayed In retry backoff, or held by the rate limiter

Beside them: a failureRate percentage, and a verdict that reads degraded once failures exceed 10% of completions.

Three shapes are worth recognising on sight:

  • waiting climbing while active stays at zero. Nothing is consuming the queue. That is a process problem, not a mail problem — the backend or cron process that registers the queue processor is down, or Redis is unreachable. Do not touch SMTP settings for this one.
  • failed climbing alongside completed. The transport is rejecting sends. Read a queue item's provider and attemptsMade, then reproduce it from the Test tab on that channel.
  • A large delayed count with almost nothing failed. Retries backing off, or the rate limiter doing its job. It resolves itself.

MAIL_QUEUE_RATE_MAX defaults to 10 per MAIL_QUEUE_RATE_WINDOW_MS (default 60,000 ms), and the limiter is Redis-backed so the cap is counted across every worker — the backend and cron processes both register a processor on this queue, so a per-process cap would have let two polite workers send twenty a minute at one mailbox.

Rate-limited jobs are moved to delayed, not failed: they go out later. So a broadcast to a few thousand customers legitimately shows a large delayed count and a slow drain, and "nobody got the email yet" is the expected state for a while. MAIL_QUEUE_MAX_ATTEMPTS (default 5) is the retry ceiling.

Raise MAIL_QUEUE_RATE_MAX only once you know what your provider's real ceiling is. The default is deliberately well under every provider's.

The queue is created lazily and only while Redis is connected. While Redis is unreachable:

  • queue/stats returns 0 waiting, 0 active, 0 completed, 0 failed, 0 delayed, and a healthy verdict, because zero failures over zero completions is 0%.
  • queue/items returns an empty list.
  • Emails are not droppedaddEmailJob falls back to sending inline, so mail still goes out, one message at a time, in the request that produced it.

An empty, green queue is therefore ambiguous: it means either "nothing to send" or "no queue at all". The Health tab's Redis component is what tells the two apart, and so does the notification service's overall status, which reads degraded whenever Redis is not connected.

The items list does not show failures

Lists the notification jobs that have not been delivered yet

Up to 200 (50 by default), each with the user, the notification id, the title, the type, the channel, the template, the provider, attemptsMade, when it was queued and its age. Its status is only ever pending or processing — active jobs first, then waiting, then delayed.

Completed and failed jobs are deliberately excluded. Bull retains the last 100 completed and 500 failed, which would bury the handful still in flight. So a non-zero failed count on the stats card has no corresponding row anywhere in this console; to identify which notifications failed you need the backend log (pm2 logs backend), where each failure is written with its provider and notification id.

The clean action

Removes old completed and failed jobs

Takes olderThan in milliseconds, defaulting to 24 hours, and returns how many were removed with the first ten ids.

It removes completed and failed jobs older than the grace period, and nothing else. It does not cancel anything waiting, does not retry anything failed, and cannot lose a message that has not been delivered. It is safe to run at any time — but it is housekeeping, not a fix: clearing a failure count does not clear its cause, and once cleared you have lost the evidence. Read the log first, clean second.

There is no admin control to pause or resume the queue. (paused appears in the stats endpoint's documented response schema; the handler does not return it.)

The health components

Redis, channels, the queue and the cache hit rate in one call

The response has an overall status plus a components object:

Component What it reports
redis connected true/false, and the cache hit rate
channels The list of channels registered at boot, and their count
emailQueue The same five counts as the Queue tab
metrics Total sent, total failed, success rate
uptime Process uptime — i.e. when the backend last restarted, and therefore how recently the channels were registered

The overall status is healthy when Redis is connected and degraded when it is not. It is not a statement about SMTP: a perfectly connected Redis with a dead mail server still reads healthy. That is what makes "send a test first" the right first move.

The card headed Channel status reads a per-channel shape (available, configured, lastCheck, error) that this endpoint does not return — the registered channels come back nested under components.channels.available as a plain list. The card therefore renders "No detailed channel data available" on a healthy install.

Read the registered channels on the Overview tab (the Notification channels card) or on Settings instead. The Redis card on the Health tab does work: it falls back to the overview payload.

A channel that never registered sends nothing, forever

Channels are registered once, during startup:

  • IN_APP and EMAIL are always registered.
  • SMS registers only if the resolved provider has credentials and the channel's own validation passes.
  • PUSH registers only if FCM or VAPID is configured and the channel validates.

A channel that failed to register is simply absent from the list, and every send to it fails. Changing an environment variable re-registers nothing: the service reads the environment at boot, so a mail, SMS or push change needs a backend restart before any of these screens can tell you the truth about it.

Boot also writes three settings rows — emailChannelStatus, smsChannelStatus and pushChannelStatus — so the rest of the platform can see which channels are live without asking this endpoint.

The test senders

Four buttons, four endpoints, and every one of them sends a real message. Each takes a userId, defaulting to your own account.

Sends a real in-app notification
Sends a real email through the configured transport
Sends a real SMS
Sends a real push notification

Email and SMS accept an override recipient and both refuse to relay: the address must match the calling administrator's own account email, and the phone must match their own account phone after normalisation. An unguarded test endpoint is an open relay.

Each response names the channels delivered and the channels failed, with the channel's own error string. That string is the diagnosis — read it rather than the red or green.

There is a fifth, older test worth knowing about:

Queues the EmailTest template to your own account address

It always goes to the calling administrator's own account email and it goes through the queue rather than inline — so it also proves the queue is draining, which the Test tab's email button does not.

SMS: a successful send is not evidence of delivery

The SMS comparison screen is System → Communication Tools → SMS Providers (/admin/system/notification/sms).

The routing rule is fixed and not symmetric:

  • One-time codes go to SMS_OTP_PROVIDERtwilio or msg91, defaulting to Twilio. That covers sign-in codes, phone verification, withdrawal codes, P2P codes and password-change codes.
  • Everything else goes to Twilio, always. There is no switch.
Validates candidate credentials against the vendor without saving or sending

Use this before writing a key into .env and restarting. It builds a throwaway provider from the values you supply, checks them against the vendor and discards it — nothing persisted, no SMS sent, and any field you omit falls back to the configured environment so you can test just the one value you are changing.

A presence check cannot tell a real MSG91 server authkey from an OTP-Widget tokenAuth, and MSG91's send endpoints accept both — and accept no key at all. The call succeeds, nothing is delivered, and every screen in this console reports a healthy channel.

Only a network call distinguishes them, which is exactly what the provider test route above makes. Presence of MSG91_AUTH_KEY proves nothing.

Templates: the two silent stoppers

System → Communication Tools → Notification Templates (/admin/system/notification/template). The screen itself — both the menu entry and the page — is gated on access.notification.template; view.notification.template and edit.notification.template are the keys the endpoints behind it enforce. A role granted only view and edit never sees the menu entry and cannot open the page, so grant all three together.

Each template carries a subject, an emailBody, an smsBody and a pushBody, plus three independent booleans — email, sms, push — saying which channels it is used for.

fetchAndProcessEmailTemplate refuses a template whose email flag is false or whose emailBody is empty, with 404 Email template not found or email not enabled. Nothing is queued, nothing is retried, and the Queue tab shows no failure — because no job was ever created.

So one flipped switch silently stops one kind of message — say, every withdrawal confirmation — while every other email on the platform keeps working. If exactly one notification type has gone missing, check its template's channel switches before anything else.

Templates can be edited and enabled but not created or deleted: the set is fixed by what the platform actually sends.

The wrapper is the other one, and it fails in the opposite direction.

Reads the shared email wrapper HTML

Every outgoing email is a template body substituted into one shared wrapper — backend/email/templates/generalTemplate.html — which supplies the header, the logo, the footer and the unsubscribe link through the placeholders %SITE_URL%, %SITE_NAME%, %LOGO_URL%, %HEADER%, %MESSAGE%, %SUBJECT%, %FOOTER%, %YEAR% and %UNSUBSCRIBE_URL%.

If that file is missing or unreadable, prepareEmailTemplate throws 500 General email template not found and every template on the platform stops at once. The endpoint is read-only — the console shows you the wrapper, it does not edit it — so this is a file-on-disk problem, usually a bad deploy or an overwritten backend/email/ directory.

The environment decides the transport, and this console never writes it

Nothing about delivery is saved from this console: there is no PUT behind the Settings tab and no write behind the SMS screen. (The PWA tab does save — but what it writes is the installable-app manifest, its icons and its screenshots, none of which touch a transport.) Everything below is read from the process environment at boot.

Channel Variables
Email — which transport APP_EMAILER: nodemailer-service, nodemailer-smtp, nodemailer-sendgrid or local
Gmail/Outlook by name APP_NODEMAILER_SERVICE, APP_NODEMAILER_SERVICE_SENDER, APP_NODEMAILER_SERVICE_PASSWORD
Plain SMTP APP_NODEMAILER_SMTP_HOST, _PORT, _SENDER, _PASSWORD, _ENCRYPTION
SendGrid APP_SENDGRID_API_KEY, APP_SENDGRID_SENDER
The box's own sendmail APP_SENDMAIL_PATH (default /usr/sbin/sendmail)
Kill switch MAIL_DISABLED=true — the platform attempts no delivery at all
SMS — code routing SMS_OTP_PROVIDER (twilio | msg91)
Twilio APP_TWILIO_ACCOUNT_SID (must start AC), APP_TWILIO_AUTH_TOKEN, and one of APP_TWILIO_PHONE_NUMBER / APP_TWILIO_MESSAGING_SERVICE_SID
MSG91 MSG91_AUTH_KEY, MSG91_OTP_TEMPLATE_ID
Push — web VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY
Push — native FCM_PROJECT_ID, FCM_CLIENT_EMAIL, FCM_PRIVATE_KEY, or FCM_SERVICE_ACCOUNT_PATH

Two behaviours that produce "nothing was even attempted":

  • MAIL_DISABLED=true drops every notification email with a debug log line and returns a synthetic job id, so nothing appears in the queue at all.
  • Reserved test domains are skipped. A recipient ending .invalid, .test, .example or .localhost is dropped before queueing. It only skips when every recipient is undeliverable, so a mixed list still goes out. If your staging accounts use @example.test addresses, this is why they receive nothing.

The Email Service entry on the system health card is a presence check on these variables — it never talks to a mail server. up there means "the right strings exist in the environment", nothing more.

Full variable reference, including the naming families that configure nothing, is in Environment variables.

Fault to first screen

Symptom Look here first
Nothing at all is delivered on any channel Test tab — one click tells you whether the transport is the problem
One notification type went missing That template's email / sms / push switches
Every email stopped at once, all types The wrapper file, then APP_EMAILER, then the Test tab
waiting rising, active zero The backend/cron processes and Redis — not the mailer
failed rising Queue item provider + attemptsMade, then pm2 logs backend
Big delayed count after a broadcast The rate limiter. Normal; wait
SMS codes arrive, notifications do not Routing: codes follow SMS_OTP_PROVIDER, everything else is Twilio only
Push silent for one customer They never accepted the browser prompt — the push test reports userHasTokens
Everything green, still nothing arrives Redis down and the queue reading all zeros; check the Health tab's Redis component