Outgoing mail: SMTP, mail providers and per-website settings#
Whoobe sends mail for form notifications, password resets and the test button of the mail screen. How it is sent is configured in Settings > Outgoing Mail, for the whole installation and, if a website needs something different, for that website alone.
Whose configuration sends a message#
| Sent with | |
|---|---|
| A form notification | the configuration of the website of the page the form is in; if that website has none of its own, the default |
| Password reset, the test button without a website chosen | the default |
| The test button with a website chosen, "resend" from the log | that website's configuration (or the default it falls back to) |
Settings for at the top of the screen picks the scope. The default always exists once it is configured; a website gets its own with Give this website its own configuration, and goes back to the default with Use the default again. Deleting a website deletes its configuration, secrets included.
What a configuration can be#
- SMTP server: host, port, TLS, user, password. Any provider that offers SMTP (Brevo, Mailgun, Amazon SES, your own server...) works this way.
- A mail provider with an HTTP API instead of SMTP. OneSignal is built in; others are one JSON file each (below).
Either way you set the sender (address and name) and, optionally, a blind copy of every message to the sender address.
OneSignal#
- In OneSignal: Settings > Email - verify your sending domain (DNS records), and note the App ID and the App API key (Settings > Keys & IDs). If the key has an IP allow-list, it must include the server that runs Whoobe.
- In Whoobe: Outgoing Mail > Send with OneSignal; fill App ID and App API key; Sender domain is the verified domain (empty = the default of the OneSignal email settings); From address must be on that domain.
- Save, then use Email Test to send one message to yourself.
What to know: OneSignal takes HTML only (a plain-text message is wrapped in HTML that keeps its line breaks), has no
CC (Whoobe refuses a message that needs one instead of dropping it) and accepts up to 5 blind copies. Messages go out
as account/transactional mail (include_unsubscribed), so a recipient who unsubscribed from the OneSignal app still
receives a password reset. Each message carries an idempotency key, so a retry cannot send it twice. The id OneSignal
gives the message is kept in the Mail Log (hover the provider name). A 403 usually means the plan does not
include email or the key lacks the permission; an answer with an empty id and errors means no recipient was
delivered - the log shows OneSignal's own text.
Secrets#
The SMTP password and the provider's secret fields (OneSignal's API key) are encrypted in the database and never
sent back to the browser: the form shows only that one is set, and a blank field on save means "keep it". This needs
MAIL_SETTINGS_ENCRYPTION_KEY in the server's environment (openssl rand -hex 32; new workspaces get one from
provision-workspace.sh). Without it the screen says so and refuses to save a secret. Keep the key: if it is lost the
saved passwords and keys cannot be read and have to be entered again.
A password saved by an older version (in clear text in the database) keeps working, and is encrypted at the next start once the key is set.
Adding a provider#
A provider is a JSON file in packages/core/mail-providers/ (built in) or in the folder named by MAIL_PROVIDERS_PATH
(yours, for example a mounted volume; a file there replaces a built-in one with the same id). The folders are read
again when a file changes: no restart. A file that is malformed or refused is skipped and logged.
{
"id": "acme", // lower case, digits, hyphens; "smtp" is taken
"label": "Acme Mail",
"description": "...", "docsUrl": "https://...",
"configFields": [ // what the screen asks for
{ "name": "apiKey", "label": "API key", "required": true, "secret": true },
{ "name": "domain", "label": "Domain", "required": true }
],
"request": {
"method": "POST",
"url": "https://api.acme.example/v3/{{config.domain}}/messages", // https only; no placeholder in the host
"headers": { "Authorization": "Bearer {{config.apiKey}}" },
"body": { "to": ["{{to}}"], "subject": "{{subject}}", "html": "{{html}}", "from": "{{fromName}} <{{from}}>" }
},
"success": {
"status": [200, 202], // default: any 2xx
"failWhen": [{ "path": "errors", "nonEmpty": true }], // an accepted status that is a failure after all
"reference": "id", // dot path of the provider's id for the message (kept in the log)
"errorFrom": ["errors"] // where to read the reason when it failed
},
"retry": { "on": [429, 503], "max": 2, "maxWaitSeconds": 10 }, // honours Retry-After
"capabilities": { "html": true, "text": false, "attachments": false, "cc": false, "bccMax": 5 }
}
Placeholders: {{to}}, {{cc}}, {{bcc}} (a list), {{subject}}, {{html}}, {{text}}, {{from}} (address),
{{fromName}}, {{replyTo}}, {{messageId}} (unique per message) and {{config.<field>}}. A value that is exactly one
placeholder keeps its type (a list stays a list) and the key is left out when it has no value; a placeholder inside
longer text becomes text. Values are put into the structure, never pasted into the JSON, so what a message contains
cannot change the request. The call goes through the same outbound guard as the gateway (no private or loopback
addresses unless allowed), and an error never contains a secret.
Trying a provider with the real service#
# credentials in a file only you can read (chmod 600), one line per field:
# ONESIGNAL_APP_ID=... ONESIGNAL_API_KEY=... ONESIGNAL_SENDER_DOMAIN=mail.example.com ONESIGNAL_FROM=noreply@mail.example.com
node scripts/mail/live-check.mjs --provider onesignal --to you@example.com --dry-run # shows the request, key masked
node scripts/mail/live-check.mjs --provider onesignal --to you@example.com # sends one message
It prints the result and the provider's message id, never the key. Needs packages/core built.
For several recipients (newsletters, campaigns)#
This is transactional mail: one message to one recipient (plus blind copies). Sending to a segment or a list is a different thing and belongs to an API Gateway endpoint (see the Brevo integration guide for the pattern).
Last updated