Notifications & SMTP
The notification module boots in noop mode by default — verification and password-reset emails are rendered and logged to the backend's stdout rather than delivered. A fresh install therefore needs no mail credentials to be usable, and dev and CI never send anything by accident.
Switching to real delivery
The quickest path is still one relay for everything:
- Open
/admin/modules/notification. - Under Delivery, change Email provider from
nooptosmtp. The SMTP connection fields appear only at this point — they carry a dependency on the provider field, so a default install shows one option instead of six irrelevant ones. - Fill in host, port and TLS mode; username and password only if your relay authenticates.
- Under Sender, set the from-address and display name.
- Save — it takes effect immediately, no restart — and send yourself a message with
POST /v1/notifications/test.
This configures the legacy default sender: until a profile under Sender profiles declares a pattern, these settings are the one profile every message uses (it appears in the delivery log as senderSlug=_legacy), and a stack that sets SMTP_HOST in docker-compose is configured the same way. Adding a profile without patterns changes nothing yet.
The password is stored as a FieldSecret: AES-256-GCM encrypted at rest, never echoed back through the API.
Separating transactional from marketing mail
Reputation is scored per domain and per IP, and vendors enforce the split contractually (MailUp's SMTP+ terms: do not use it for promotional emails). Once a fork sends campaigns, give each workload its own sender:
- Under Sender profiles, add a profile — say Transactional — pick its provider, fill its identity and credentials, and give it the pattern
*. It is now the default for everything. - Add a second profile — Campaigns — on its own domain and relay, with the patterns your campaign categories use:
crm.*,marketing. (A category with no dot, likemarketing, is matched only by the exact pattern;marketing.*would not capture it.) - Optionally pin the access path explicitly: a third profile with
auth.*carries verification, reset and security mail even if someone later edits the default. - Save. The most specific pattern wins, so
auth.verify_emailgoes to theauth.*profile,crm.campaigntocrm.*, and anything else to*. - Prove each profile:
POST /v1/notifications/testwith{"to": "[email protected]", "sender": "<slug>"}. This is the only check that exercises a profile's secret before real mail depends on it.
A profile with no patterns is a draft — it receives nothing and is not validated beyond pattern grammar, so you can prepare a sender before routing traffic to it. Once any profile declares a pattern, exactly one must declare *; saving is refused otherwise (notification.sender_no_default).
Several senders for the same workload
Step 2 gives campaigns a sender. If you need more than one — an events sender and a newsletter sender on different domains, say — do not give them competing patterns and do not add a pattern per campaign. Instead fill in Explicitly selectable for (allowed_types) with marketing on each, and leave their patterns empty. They then appear in the campaign editor's sender picker, and the campaign's author chooses per campaign.
A profile that is selectable but routes nothing is a supported, deliberate shape: it intercepts no category, so it cannot steal the mail your * profile carries. The "exactly one *" rule is a routing rule and does not apply to it. What does apply is completeness — a selectable profile with an unknown provider or a missing non-secret field is refused at save, same as a routing one.
Leave allowed_types empty on a profile you only want routing to reach. It is not a switch that improves a sender; it is a permission that also makes the profile's label and From: address visible to everyone who can send a campaign.
Marketing needs a one-click unsubscribe first
On a fresh install, the save above is refused with a 422. Adding marketing to a profile's Explicitly selectable for returns notification.public_base_url_missing, and nothing is wrong with the profile. Marketing mail must carry the RFC 8058 List-Unsubscribe headers, those headers have to name a URL, and Public API base URL (public_api_base_url, under Delivery) is empty by default while Require one-click unsubscribe (require_one_click_unsubscribe) is on by default. A profile that cannot carry the headers is not admitted for marketing.
Set Public API base URL to the bare https:// origin this API answers on from the public internet — scheme and host only. A path, a query string, a fragment, a trailing //, plain http, or embedded user:pass@ credentials are all treated as not set, because none of them can produce a working unsubscribe link (and credentials there would be copied into every marketing message). Save it, then save the profile again.
The sibling refusal is notification.sender_driver_no_one_click: the base URL is fine, but the profile's Provider is a driver that cannot guarantee the headers reach the wire. Pick a driver that can, drop marketing from that profile's send types, or accept the trade below.
Turning the requirement off is a real trade. With Require one-click unsubscribe off, the profile saves and marketing goes out without an unsubscribe header. Large mailbox providers require one-click unsubscribe from bulk senders and penalise domains that omit it, and several jurisdictions require an easy withdrawal mechanism for commercial email. The field turns off only on false, 0 or no (any capitalisation). Any other value — including on, enabled, or a typo — leaves the requirement in force. That asymmetry is deliberate: this is not a setting that should switch itself off by accident, and a message already delivered without an unsubscribe header cannot be recalled.
The same rule applies at send time, not only at save time. A profile that carries mail only through category patterns declares no send type, so there is nothing for the save-time check to see — but if a marketing message is ever routed to it, the check runs again at the moment of the send and the message is refused rather than delivered without an unsubscribe header.
What a link scanner costs you
A recipient who clicks the unsubscribe link in a footer is recorded as an address-level opt-out from all marketing. So is anything else that follows that link: a corporate link scanner, an anti-malware proxy, or a mail client prefetching URLs the moment the message arrives. No human has to be involved.
That record does not expire, nothing in the admin console removes it, and a data-erasure request deliberately preserves it (deleting the fact that an address asked to stop would let the next campaign mail it again). There is no operator path to reverse an opt-out today — an endpoint to remove one is the natural next step, and until it exists the state is final.
Two things reduce the exposure, neither of which is a fix:
- Set Unsubscribe page URL (
unsubscribe_page_url) to a page you host. The footer then links there instead of straight at the API, and the page must not call the API when it loads — only when a visitor presses a button. A page that unsubscribes on load reproduces the problem exactly. - Read a sudden cluster of opt-outs from a single recipient domain as a scanner rather than as churn, and check it against the delivery log before drawing conclusions about a campaign.
Nothing is silently rerouted. If a category matches no profile, or its profile is incomplete, the send fails and the delivery log row says why (sender=campaigns driver=mailup err=not_configured missing=mailup_user). Falling back to the default would push promotional mail through the transactional sender — the thing this feature exists to prevent.
TLS mode
| Mode | Port | When |
|---|---|---|
starttls | 587 | The default, and what nearly every provider wants |
tls | 465 | Implicit TLS, for relays that require it |
none | 25 or 1025 | Local capture only — a dev mail catcher. Never across a network |
Port 25 is blocked outbound by most cloud providers, so starttls on 587 is the right first attempt.
Providers
smtp covers every hosted relay below and any internal MTA — credentials are optional, so an unauthenticated relay on a private network is a first-class setup. mailup talks to MailUp's SMTP+ API directly: create an SMTP+ user in the MailUp console after authorizing a trusted sender, and enter its username (s12345_67) and secret; the notification category is reported to MailUp as the CampaignCode, so their statistics line up with your routing (leave it to MailUp's own default by sending an empty category). A MailUp "success" means accepted, not delivered — the same guarantee an SMTP 250 gives. A Reply-To differing from the from-address has to be enabled on the account by MailUp support.
Every hosted relay maps onto the same SMTP fields. Confirm the host against your provider's current documentation — these are stable but not ours to guarantee.
| Provider | Host | Username | Password |
|---|---|---|---|
| Amazon SES | email-smtp.<region>.amazonaws.com | SES SMTP credentials — generated in the console, not your AWS access key | The matching SMTP secret |
| SendGrid | smtp.sendgrid.net | The literal string apikey | Your API key |
| Postmark | smtp.postmarkapp.com | Server API token | The same token |
| Resend | smtp.resend.com | The literal string resend | Your API key |
Two things trip people up regardless of provider:
- The from-address must be verified with the provider, and for anything beyond a trickle you need domain authentication (SPF and DKIM records). An unverified sender is rejected at the relay, so it looks like a credentials problem when it is a DNS one.
- Amazon SES starts every account in sandbox mode, where you may only send to verified addresses. Requesting production access is a separate step, and until it is granted mail to your users silently fails.
What gets sent
The core base sends transactional mail only, all of it from the auth module: email verification, password reset, suspicious-login and new-device notices, and admin invites.
Transactional mail cannot be opted out of. Preference checks are skipped for it by design — verification and reset messages are required for the product to work. The unsubscribe footer still links to the preferences page, where a user can opt out of marketing categories, with a note that security mail will keep arriving. If your fork adds marketing mail, it must mark it as such or preferences will silently not be honored.
Templates
Templates are seeded into MongoDB on first start, and from then on the database is the source of truth. Edit one at /admin/modules or through PUT /v1/notifications/templates/{templateId}; overriding flips it out of system ownership. Deleting the override restores the shipped default on the next start — that is the undo.
Subjects and plain-text bodies render through Go's text/template; HTML bodies render through html/template, so the HTML path gets contextual escaping. Every send is automatically given an unsubscribe URL, a preferences URL, the app name, and the support email, so a template can use those without the calling module passing them.
When mail does not arrive
Work down this list in order:
- Is the provider still
noop? Then it never left the building — the rendered message is in the backend's stdout. This is the single most common cause. - Check the delivery log at
GET /v1/notifications— filter bycategory,status, orsender. Afailedrow names the profile and the reason (sender=… smtp op=auth code=535,http=401 status=error code=401,err=no_sender_for_category); Orkestra tried and the sender refused. No row at all means the send was never attempted — usually a pre-flight guard found the category's profile unusable, which forauthmail surfaces as a 503 on signup. - Is the address suppressed? A suppression is keyed by address and silently drops delivery. There is no bounce ingestion in the base, so suppressions are added manually — check before assuming a config fault.
- Was it deduplicated? Every send carries an idempotency key, and a repeat within the hour returns the earlier result instead of sending again. Two clicks on "resend" produce one email, on purpose.
- Then look at SPF/DKIM and the provider's own log. By this point the problem is usually at the relay, not in Orkestra.
- With several profiles, check which one carried it.
senderSlugon the row tells you; a category that rode the default when you expectedcrm.*means the pattern is misspelled — a pattern with no dot matches exactly, andcrm.*never matches the barecrm. - If a campaign named its sender, check
attemptedSenderSlug. On a row that failed the sender check this field holds the slug the send asked for, next to the reason:err=sender_not_found(no such profile — usually renamed or deleted out from under a campaign),err=sender_not_eligible(the profile exists butmarketingis not in itsExplicitly selectable forlist),err=not_configured missing=<fields>(eligible, but its driver is missing the named non-secret fields — or a secret, in which case the profile looks complete on the settings page and only the send says otherwise), anderr=unknown_driver(its Provider names a driver this build does not have). The last two carrydriver=<provider>as well, and they are reported the same way whether the sender was named explicitly or reached by routing — the completeness check is the same one. AnattemptedSenderSlugofinvalid(witherr=sender_invalid) means the slug was malformed rather than merely wrong — it is a constant marker, deliberately not the input echoed back. A campaign left on the default sender records no slug at all and fails witherr=no_sender_for_categorywhen nothing routesmarketing. - On a marketing send, three reasons are about unsubscribe, not about the sender.
err=one_click_unavailable— the message would have gone out with no RFC 8058 unsubscribe header while Require one-click unsubscribe is on, because the profile's driver cannot add the headers or Public API base URL is unset or is not a barehttpsorigin; this is the refusal described under "Marketing needs a one-click unsubscribe first" above, arriving at send time.err=optout_lookup_unavailable— the opt-out list could not be read, so the send was withheld rather than risked; the address may or may not have opted out, and the check is deliberately fail-closed.err=unsubscribe_token_unavailable— the opt-out list answered, but the one-time token the header needs could not be issued. The last two are database faults, not configuration: retry once the database is healthy. A message suppressed because the address really did opt out is not a failure at all — it is asuppressedrow whose reason readsmarketing_optout.
:::note Scope Email only. The interface was designed for SMS, push, and webhooks, but none are implemented. Sends are synchronous — there is no queue. No marketing automation, segmentation, or A/B testing; no bounce or complaint ingestion; DKIM signing is the relay's job. :::