Mooflair sends email through Mailgun and WhatsApp through Beamvia: sign-up and password codes, workspace invites, new members, ownership changes and security notices. Code never calls a provider directly. It queues a message, and the message service on the server sends it, retries it and records what happened. Admins see every message in Admin › Delivery › Messages.
How a message travels
- Code calls
messageQueue({ template, to, data })(src/controllers/message/queue.ts). It picks the channels, inserts oneMessageper channel with status queued, and returns at once. - The message service (
src/services/message, started byserver.ts) claims the next due message atomically (findOneAndUpdatewith a 60-second lease), so two servers never send the same one. - It renders the template in the recipient's language and sends it through the channel's provider. Success sets sent with the provider's id.
- The provider's webhook later moves it forward: delivered, read, or failed / bounced / complained. Webhooks only update; they never send.
| Status | Meaning |
|---|---|
| queued | Waiting for its turn (or its next try) |
| sending | A server holds its lease and is sending |
| sent | The provider accepted it |
| delivered / read | The provider confirmed delivery / the email was opened or the WhatsApp read |
| failed | Out of tries, or an error that won't get better (bad key, channel off) |
| bounced / complained | Permanent bounce or spam report: that address gets no more email |
| canceled | An admin canceled it while queued |
Who gets which channel
to: { userId }: critical templates (codes, password, security, ownership) go to every channel the person has: email (if not bounced) and WhatsApp (only a verified number). Other templates go to email, else WhatsApp.to: { email }orto: { phone }: that one channel (sign-up codes, invites to people without an account, admin tests).- A channel that is off or not set up is skipped. If no channel is left, nothing is queued and the caller says so.
Retries and pace
- Errors retry after 30 s, 2 min, 10 min, 1 h, up to 5 tries. Beamvia's plan (402) and daily quota (429) errors wait a day. Errors that won't improve (400/401/403, channel off) fail at once.
- Each server runs
rate.concurrentworkers per channel, paced torate.secondsends per second. A change toconcurrentapplies after a restart. - At most
rate.recipient.hourmessages reach one address or number an hour (critical ones skip this cap); extra ones wait 10 minutes. - Delivery is at least once: a crash between the provider's answer and our update can repeat a send after the lease runs out.
- On shutdown the service stops claiming, finishes what it's sending, and hands back any lease.
Codes are never stored in clear
Templates list the data keys to mask (the OTP code). Those values are sealed with the encryption key while queued, and replaced by •••••• once sent or failed, in the data, the subject and the preview. Messages with codes can't be retried or resent from admin.
Templates
One folder per template under src/lib/message/template/<domain>/<name>/index.tsx, listed in src/lib/message/template/index.ts, keyed <domain>.<name>. Each has isCritical, mask, sample data and a render per channel:
email: a React Email component in the shared layout (template/layout), rendered to HTML and plain text.whatsapp: short text with WhatsApp formatting.
Text lives in i18n under message.<domain>.<name> (en, id) and is rendered in the recipient's language (their account language, else the one given).
| Template | Critical | Channels | Sent when |
|---|---|---|---|
authentication.otp | yes | email, WhatsApp | Sign-up and password reset codes |
authentication.password.changed | yes | email, WhatsApp | A password reset finished |
authentication.email.changed | yes | The account's email changed (to the old one) | |
authentication.sign.in.new | yes | A sign-in from a new device | |
workspace.invite | no | email, WhatsApp | Someone is invited to a workspace |
workspace.member.joined | no | An invite was accepted (to the owner) | |
workspace.ownership.transferred | yes | email, WhatsApp | You became a workspace's owner |
account.export.ready | no | Your data export is ready (link for 7 days) | |
account.deletion.scheduled | yes | email, WhatsApp | You asked to delete your account (date, keep link) |
billing.receipt | no | A payment went through (amount, invoice number and link) | |
billing.payment.failed | yes | email, WhatsApp | A renewal payment failed (what to do before the workspace drops to Free) |
billing.trial.ending | no | The trial ends in a few days | |
billing.subscription.canceled | no | A plan was canceled (until when it keeps working) | |
billing.limit.reached | no | A meter reached 80% or 100% of the plan this month | |
site.contact | no | A message from the contact page (to the site's contact address) | |
workspace.request | no | A new invite, an invite for another address, or a seat is asked for | |
admin.alert | yes | email, WhatsApp | To every administrator: an engine is down or back, or errors piled up () |
Add one: its folder, its text in both message files, one line in the list.
Providers
Plug-and-play like payment methods: src/lib/message/provider/<key>/index.ts with the channels it serves, isReady, send and webhook, listed in provider/index.ts. Each channel picks its provider in message.<channel>.provider.
| Provider | Channel | Sending | Webhook |
|---|---|---|---|
| Mailgun | POST /v3/<domain>/messages on the US or EU API, basic auth api:<key>; our id goes in v:message | /api/message/webhook/mailgun, HMAC-SHA256 of timestamp + token with the signing key; delivered, opened, complained, permanent failures | |
| Log | email, WhatsApp | Prints each message, codes included, to the server console. Development only: never ready when NODE_ENV=production | none |
| Beamvia | POST https://www.beamvia.com/api/v1/message, Bearer key (scope message:send) | /api/message/webhook/beamvia, X-Beamvia-Signature = sha256= + HMAC of <X-Beamvia-Timestamp>.<body>, older than 5 minutes refused; MESSAGE_UPDATED |
Beamvia runs on the WhatsApp Web protocol, so a number can be banned: keep the pace low; codes can always go by email.
Admin pages
- Delivery › Messages (
/admin/delivery/message): counters (queued now, sent today per channel, failure rate and failures in 24 h), the queue and history in one table with filters (status, channel, template, recipient, user, workspace), live every 10 seconds. Click a recipient for the detail: the rendered email in a sandboxed frame or the WhatsApp text, the timeline, the error, and Retry (failed), Cancel (queued), Resend (a new message). The sidebar shows the failures of the last 24 hours. - Configuration › Messages › Email / WhatsApp: the provider (Mailgun / Beamvia, or the server console in development), turn the channel on, keys (never shown back; blank keeps the saved one), sender and reply-to (email), pace, the webhook URL to paste in the provider, and the last message's status.
- Configuration › Messages › Templates: every template per channel and language with sample data, and Send test to an address or number (queued like any message).
Configuration
| Key | Default | Meaning |
|---|---|---|
site.url | http://localhost:3000 | Public address used in links that leave the app (set it in Admin › Configuration › Site) |
message.email.provider, message.whatsapp.provider | mailgun, beamvia | log prints to the server console instead (development only) |
message.email.from | Mooflair, "" | Sender name and address (required to turn email on) |
message.email.reply.address | "" | Reply-To |
message.email.mailgun | off | enabled, api.key, domain, region (us/eu), webhook.signing.key |
message.email.rate | 10/s, 4 at once, 20 per recipient an hour | Pace per server |
message.whatsapp.beamvia | off | enabled, api.key, channelId, webhook.secret |
message.whatsapp.rate | 1/s, 1 at once, 6 per recipient an hour | Pace per server |
History is kept 90 days (critical messages 30 days).
FAQ
Can I sign up locally without Mailgun or Beamvia?
Yes. Set the channel's provider to Server console (development only) in Admin › Configuration › Messages › Email or WhatsApp. Codes then appear in the dev server's output. It never sends in production.
Why did a message go to email and not WhatsApp?
Non-critical messages go to one channel, email first. WhatsApp also needs a verified number.
Does the bell use this queue?
No. In-app notifications are written straight to the database; the queue is for email and WhatsApp.