Mailer Module¶
Customer password recovery integration¶
The server publishes customer.password_reset.delivery_requested once a customer-recovery
workflow rule has picked a delivery_route — see the
event contract. No rule,
no event: the server owns the token, the module owns the templates and the provider.
The module handles the event like any other trigger, so wiring it up is configuration, not code:
- Create the trigger — MailerAddTrigger with
event: "customer.password_reset.delivery_requested"and the route conditionarguments: { "match": { "delivery_route": "customer-recovery" } }. Every subscriber receives the event, so without the condition this module would also send on routes meant for another channel. - Create the template for that trigger with
kind: "TRANSACTIONAL"— a recovery email must ignore marketing opt-outs and send windows, and it needs no unsubscribe link. Put the link in as{{{reset_url}}}: triple braces, so the URL is not HTML-escaped. - Point the template at a provider profile with
trackClicksswitched off. Click tracking rewrites every link through the provider, and the recovery link carries the secret.
Available placeholders are the payload fields: request_id, customer_id, brand,
language, country, first_name, last_name, email, phone, delivery_route,
reset_url, requested_at, expires_at. Timestamps are Unix seconds, and the module adds
requested_at_utc and expires_at_utc with the same moments as 2026-09-17 09:58:30
strings — templates have no date formatting of their own (see
Platform event enrichment).
Repeated delivery of one event does not produce a second email: the deduplication key uses
request_id. A new recovery request by the same person is a new request_id and does send.
reset_url is replaced with *** in the stored placeholder data and in the journal, and
the rendered body is not written to the journal at all — see
Secrets in placeholder data.
The same event can drive several channels at once: the SMS module subscribes to it with its own trigger. The platform cannot split channels itself — one recovery request emits exactly one event, and delivery can be claimed only once — so the fan-out happens here, in the modules.
There is no delivery acknowledgement back to the server: a queued email means the module accepted the request, nothing more.
The mailer module sends email from the platform: confirmation codes, statements, alerts and marketing campaigns. It assembles the message, queues it, picks a provider, and then tracks what happened — delivered, bounced, complained, opened, clicked.
Everything in this module exists to protect one thing: the reputation of the sending domain. A domain that starts landing in spam takes the login codes down with the promotions, so bounces, complaints, unsubscribes and sender-domain verification are first-class features rather than afterthoughts.
Workflow email template integration¶
The server publishes customer.email_template.delivery_requested when a workflow rule runs
the action customer.email_template.request_delivery — see the
event contract. No rule,
no event. The rule names the template by its id in template; the server checks neither
the template nor the address.
The module handles the event like any other trigger, so wiring it up is configuration, not code:
- Create the trigger — MailerAddTrigger with
event: "customer.email_template.delivery_requested"and the template conditionarguments: { "match": { "template": "12" } }. Every subscriber receives every template request, so without the condition the trigger would fire for all of them. - Bind template 12 to that trigger with
triggerId.
Repeat this for every template used in workflow rules: one trigger per template.
Available placeholders are the payload fields: request_id, customer_id, brand,
language, country, first_name, last_name, email, phone, template,
fallback_template, requested_at. The module adds requested_at_utc with the same moment
as a 2026-09-17 09:58:30 string (see Platform event enrichment).
An event with an empty email carries no recipient of its own, so the module looks the
address up by customer_id; an event that still has no address is skipped and counted as an
error.
Repeated delivery of one request does not produce a second email: the deduplication key uses
request_id. Every execution of the action is a new request_id, so two matching rules
send two emails.
On a cashier deposit the action also runs on cashier.deposit.approved, and the request
still carries no deposit amount. A trigger of this module on the
cashier.deposit.approved event sends its
own email, so do not configure both for the same notification.
On a margin call the action runs on account.margin_call.alert, which CRM rules can use as
well, and the request carries no account login or margin values. It fires on every entry
into Margin Call. Triggers of this module on
account.margin_call.alert or
account.margin_state send their own emails,
so do not configure them for the same notification.
On a credit the action runs on credit.created, and the request carries no credit amount.
A trigger of this module on trade.add matching cmd
7 sends its own email for the same credit, so do not configure both.
fallback_template is not applied by trigger matching. Rendering the template named in the
event and falling back to fallback_template is a planned mode of this module; once it is
enabled, remove the match triggers, otherwise each request is sent twice.
There is no delivery acknowledgement back to the server.
Platform event enrichment¶
Some platform events carry identifiers instead of people. cashier.deposit.approved and
cashier.withdrawal.approved name a customer_id and a login;
account.margin_call.alert names only a login. None of them carries an email address, and a
template that shows a balance needs more than the event has.
The module fills the gaps itself, through the commands the platform publishes on the Moleculer bus:
| Command | What it adds | Permission |
|---|---|---|
MngGetAccountInfo |
customer_id for a login, currency, group, balance and margin |
see_accounts_balance for the money fields |
MngGetCustomerContacts |
the real email address, unmasked | see_customer_contacts |
MngGetCustomer |
first and last name, language, country, status, desk | see_customers |
GetTradesByLogin |
number of open positions | — |
GetGroups |
margin_call and margin_stopout, the margin call and stop out levels |
— |
Requests go out as the service manager from [platform] managerId, so that account needs
those permissions and needs to see every desk. A manager without see_accounts_balance
still gets an answer: the platform returns zeros instead of an error, and the email
would show 0.00 as if the account were empty.
Enrichment runs when the event carries no recipient of its own, or when it names a
login. An event that already has the address and no account — a recovery request, for
example — costs no platform calls at all.
What templates get¶
customer—first_name,last_name,full_name,email,phone,language,country,status,desk;accounts[0]andaccount—login,currency,group,balance(also asamount),equity,credit,bonus,margin_used,free_margin,margin_level,open_positions,margin_call_level,stop_out_level;- the same account values under flat names:
account_login,account_currency,free_margin,margin_level,open_positionsand the rest; <field>_utcfor every Unix timestamp in the payload, soapproved_timealso arrives asapproved_time_utc, formatted2026-09-17 09:58:30. Templates have no date formatting, so the string is prepared here andmergegives it the name a template expects.
Part of this needs no platform call at all. customer.created carries the customer flat —
first_name, email, phone, language, country — and those values are also exposed as
customer.*, so a template written as {{customer.first_name}} works whichever event
triggered it. status is the exception and is never taken from an event: in
customer.created it is the customer status, in kyc_step.update the status of a KYC
step, and the same name would otherwise carry two different meanings into one email.
accounts[0] is the account named by the event, not the first account of the customer.
A customer may hold many, and a deposit, a withdrawal or a margin call is always about one
of them.
Event fields win. Whatever the event carries stays untouched, including inside
accounts[0]: account.margin_call.alert describes the moment of the transition while
the platform answers about now, and the email has to show the numbers that caused it.
merge is applied twice — before enrichment, so identifiers are found even under
non-standard names, and after it, so added fields can be renamed
(approved_time_utc → value_datetime_utc).
Cost and failure¶
A platform that refuses one of these calls does not stop the email: the module logs a warning and sends what it has. The recipient is the exception — an event that still has no address is skipped and counted as an error, which is visible in the module log.
One event costs up to five commands, of which the group list is cached for
enrichCacheSec (300 seconds by default). Contacts and the customer card are deliberately
not cached: they are personal data, and the address may have changed seconds before the
event arrived. Enrichment happens after the arguments condition and after the brand
check, so an event meant for another route or another brand costs nothing.
[triggers] enrich switches the whole thing off; [triggers] enrichCacheSec sets the
group cache.
Events published without a rule¶
These arrive straight from the Router mapping, so a trigger is all that is needed:
| Event | Delivers | Recipient comes from |
|---|---|---|
customer.created |
registration, welcome message | the event itself |
customer.update |
status or lifecycle change, with previous values | customer_id |
kyc_step.update |
KYC step approved or rejected, with reject_reason |
customer_id |
account.update |
leverage, group or enable changed | login |
trade.add, trade.closed |
position opened or closed | login |
account.margin_state |
every margin transition, including stop out and recovery | login |
Two of these deserve a condition rather than a bare subscription. trade.add,
trade.closed and account.update fire continuously on a live platform, and each event
with a login costs four platform calls, so narrow them with arguments.
account.margin_state is the superset of account.margin_call.alert: subscribe to both
and the customer is told twice about one margin call.
Events this makes usable¶
| Event | Delivers |
|---|---|
cashier.deposit.approved |
deposit confirmation |
cashier.withdrawal.approved |
withdrawal confirmation |
account.margin_call.alert |
margin call warning |
customer.email_template.delivery_requested |
the template chosen in a workflow rule |
Each needs an active workflow rule on the platform — cashier.publish_approved for the
two cashier events, account.margin_call.publish for the margin call,
customer.email_template.request_delivery for the template request. Without a rule the
platform publishes nothing, and the trigger in the mailer never fires.
What it covers¶
- Providers — 9 integrations: SMTP plus API providers, each an adapter with its own credentials schema and capability set.
- Queue — FIFO with attempts, exponential backoff, stuck recovery and expiry.
- Routing — the provider is chosen by recipient domain and message class, with failover to backup profiles and per-profile rate and daily limits.
- Content assembly — safe
{{placeholder}}substitution with HTML escaping, a builder tree serialized through a tag allow list, and a generated plain-text part. - Templates and triggers — a platform event renders a template and queues the email without a caller.
- Campaigns — a managed rollout over a platform segment, with a cursor, resume and dedupe.
- Suppression list — hard bounces, blocks, complaints and unsubscribes are never emailed again.
- One-click unsubscribe — signed token,
List-UnsubscribeandList-Unsubscribe-Post. - Reputation guard — a profile is paused automatically when its bounce or complaint rate crosses the threshold.
- Domain verification — DKIM and SPF state of the sender domain, asked of the provider.
- Pre-send lint — Gmail clipping, image-only bodies, missing alt text, broken links.
Methods¶
Names in the table are the TCP command names; the REST column is the HTTP entry point of the same method.
Emails¶
| Method | REST | Description |
|---|---|---|
| MailerSendEmail | POST mailer/email |
Queue one email |
| MailerSendBulk | POST mailer/email/bulk |
Queue up to 10 000 recipients |
| MailerResendEmail | POST mailer/email/resend |
Send a copy of an existing email |
| MailerCancelEmail | POST mailer/email/cancel |
Cancel an email still in the queue |
| MailerPreviewEmail | POST mailer/email/preview |
Assemble the message without sending |
| MailerLintEmail | POST mailer/email/lint |
Pre-send warnings |
| MailerSendTestEmail | POST mailer/email/test |
Send a test to your own address |
| MailerFindEmail | GET mailer/email/find |
Find emails by address, provider id or campaign |
| MailerGetEmail | GET mailer/email/message/:id |
One email with attempts and events |
| MailerGetEmails | GET mailer/emails/list |
Email history with filters and aggregates |
| MailerGetStats | GET mailer/email/stats |
Delivery, bounce, complaint, open and click rates |
| MailerGetLastEmailsByParents | — | Last email time for a batch of recipients |
Campaigns¶
| Method | REST | Description |
|---|---|---|
| MailerAddCampaign | POST mailer/campaign |
Create a campaign over a platform segment |
| MailerUpdateCampaign | PUT mailer/campaign |
Update a campaign |
| MailerCancelCampaign | POST mailer/campaign/cancel |
Stop a campaign and its queued emails |
| MailerResumeCampaign | POST mailer/campaign/resume |
Continue an interrupted campaign |
| MailerEstimateCampaignRecipients | POST mailer/campaign/estimate-recipients |
Count recipients before the rollout |
| MailerDeleteCampaign | DELETE mailer/campaign |
Delete a campaign |
| MailerGetCampaigns | GET mailer/campaigns/list |
Campaigns with rollout progress |
Templates and triggers¶
| Method | REST | Description |
|---|---|---|
| MailerAddTemplate | POST mailer/template |
Create a template |
| MailerUpdateTemplate | PUT mailer/template |
Update a template |
| MailerCloneTemplate | POST mailer/template/clone |
Copy a template, for another language |
| MailerDeleteTemplate | DELETE mailer/template |
Delete a template |
| MailerGetTemplates | GET mailer/templates/list |
Templates of the brand |
| MailerAddTrigger | POST mailer/trigger |
Subscribe an event to templates |
| MailerUpdateTrigger | PUT mailer/trigger |
Update a trigger |
| MailerDeleteTrigger | DELETE mailer/trigger |
Delete a trigger |
| MailerFireTrigger | POST mailer/trigger/fire |
Fire an event by hand |
| MailerGetTriggers | GET mailer/triggers/list |
Triggers of the brand |
| MailerGetTriggerSubscriptions | GET mailer/triggers/subscriptions |
What the module listens to |
Providers, profiles and agents¶
| Method | REST | Description |
|---|---|---|
| MailerGetProviderAdapters | GET mailer/providers/adapters |
Implemented adapters and capabilities |
| MailerAddProvider | POST mailer/provider |
Register a provider |
| MailerUpdateProvider | PUT mailer/provider |
Update a provider |
| MailerDeleteProvider | DELETE mailer/provider |
Soft-delete a provider |
| MailerGetProviders | GET mailer/providers/list |
Provider catalogue |
| MailerAddProfile | POST mailer/providerProfile |
Create a provider profile |
| MailerUpdateProfile | PUT mailer/providerProfile |
Update a provider profile |
| MailerDeleteProfile | DELETE mailer/providerProfile |
Soft-delete a provider profile |
| MailerGetProfiles | GET mailer/providerProfiles/list |
Profiles with domain and pause state |
| MailerGetMyProfiles | GET mailer/providerProfile/available/me |
Profiles and templates for the current manager |
| MailerGetProfileBalance | GET mailer/providerProfiles/balance |
Provider quota with low-balance flags |
| MailerVerifyProfileDomain | POST mailer/providerProfile/verify-domain |
DKIM and SPF state of the sender domain |
| MailerAddAgent | POST mailer/agent |
Link a manager to a sender address |
| MailerUpdateAgent | PUT mailer/agent |
Update the link |
| MailerDeleteAgent | DELETE mailer/agent |
Remove the link |
| MailerGetAgents | GET mailer/agents/list |
Links of the brand |
Suppressions and unsubscribe¶
| Method | REST | Description |
|---|---|---|
| MailerAddSuppression | POST mailer/suppression |
Suppress an address |
| MailerImportSuppressions | POST mailer/suppression/import |
Import up to 50 000 addresses |
| MailerDeleteSuppression | DELETE mailer/suppression |
Remove a suppression |
| MailerCheckSuppression | GET mailer/suppression/check |
Check an address before sending |
| MailerGetSuppressions | GET mailer/suppressions/list |
Suppression entries |
| MailerHandleUnsubscribe | POST mailer/unsubscribe |
Public one-click unsubscribe |
Send windows, webhooks and service¶
| Method | REST | Description |
|---|---|---|
| MailerAddSendWindow | POST mailer/sendWindow |
Allowed sending hours for a country |
| MailerUpdateSendWindow | PUT mailer/sendWindow |
Update a send window |
| MailerDeleteSendWindow | DELETE mailer/sendWindow |
Delete a send window |
| MailerCheckSendWindow | GET mailer/sendWindow/check |
Whether a country can be emailed now |
| MailerGetSendWindows | GET mailer/sendWindows/list |
Send windows |
| MailerAddWebhook | POST mailer/webhook |
Create a provider event URL |
| MailerUpdateWebhook | PUT mailer/webhook |
Update a webhook |
| MailerDeleteWebhook | DELETE mailer/webhook |
Delete a webhook |
| MailerGetWebhooks | GET mailer/webhooks/list |
Webhooks of the brand |
| MailerGetWebhookEvents | GET mailer/webhookEvents/list |
Raw provider events |
| MailerHandleWebhook | POST mailer/handleWebhooks/:uuid |
Public endpoint the provider calls |
| MailerPing | GET mailer/ping |
Cheap liveness probe |
| MailerHealth | GET mailer/health |
Full module state |
Entities¶
| Entity | Scope | Purpose |
|---|---|---|
providers |
global | Email integration catalogue; name is the adapter name |
providerProfiles |
brand | Credentials, sender identity, tracking flags, limits, pause state, domain status |
agents |
brand | Manager-to-sender-address link, used by INDIVIDUAL profiles |
triggers |
brand or global | Platform event to template subscription; arguments holds the firing condition, merge renames event fields |
templates |
brand | Subject, HTML, text part, builder tree, language, message class |
campaigns |
brand | Managed rollout: recipient set, schedule, counters, cursor |
emails |
brand | Emails, queue and state: status, kind, attempts, bounceType, openCount, clickCount |
webhooks |
brand | Public provider event URLs and their secrets |
webhookEvents |
via webhooks |
Raw provider payloads and deduplication |
suppressions |
brand | Addresses that must not be emailed: bounces, complaints, unsubscribes, manual blocks |
sendWindows |
brand | Allowed sending hours per country (off by default) |
Everything is scoped by brand. Desks are not part of the model: a recipient address,
a template and a provider profile belong to a brand, not to a department.
Desks appear in two places only, and in both of them they come from the platform: the
customer desk in the analytics row, and the campaign deskFilter, which the platform
intersects with the desks of the campaign author.
Secrets in placeholder data¶
A platform event can carry a one-time secret in its payload — a password recovery link,
a confirmation code, a temporary password. The module renders the email from the full
data, but stores a redacted copy: secret fields are replaced with *** in the data
column and in the journal context. The mask is kept instead of dropping the key, so an
investigation can tell "hidden on purpose" from "the platform never sent it".
Recognised out of the box: reset_url, recovery_url, token, access_token,
refresh_token, api_key, secret, password, otp, one_time_code,
verification_code, pin and their spellings — names are compared ignoring case,
dashes and underscores, so reset_url and resetUrl are the same field. The list is
extended in the module configuration and the built-in entries cannot be switched off.
The journal does not receive the body either: the snapshot written to a journal record
leaves out html, text and structure, because a link already rendered into HTML
cannot be masked and a journal record lives for a long time. The content is read from
the emails table by the same id when an incident is investigated.
This does not cover the body stored in the emails table: it is kept until the email is
sent and stays available for resending, and a link inside it is part of the text.
Recovery tokens expire (30 minutes for password reset), which bounds how long a stored
link is worth anything.
Email statuses¶
| Status | Meaning |
|---|---|
PENDING |
In the queue, not sent yet |
IN_PROGRESS |
The queue is handing it to the provider right now |
SENT |
Provider accepted it; the receiving mail server has not answered |
DELIVERED |
Receiving server accepted the message |
BOUNCED |
Delivery failed; bounceType says whether it was hard, soft or a block |
COMPLAINED |
Recipient marked it as spam |
REJECTED |
Provider refused the message |
EXPIRED |
No final event arrived within the allowed window |
FAILED |
Sending failed and no attempts are left |
SENT is not final: the provider accepting a message and the recipient's server accepting it
are different events. The only transition allowed out of a final status is
DELIVERED → COMPLAINED — a spam complaint arrives after delivery, sometimes hours later, and
it is the most damaging signal a sender can get, so it is never dropped.
Opens and clicks are not statuses. They live in their own columns (openedAt,
openCount, clickedAt, clickCount, lastClickedUrl): otherwise one open would overwrite
the fact of delivery and a second one would overwrite a complaint.
Email class¶
Every email has a kind: TRANSACTIONAL or MARKETING (the default, and the restrictive
one).
TRANSACTIONAL |
MARKETING |
|
|---|---|---|
| Send window | ignored | applied |
| Unsubscribe, spam complaint | do not block | block |
| Hard bounce, block, manual entry | block | block |
| Unsubscribe link and headers | not added | always added |
A confirmation code must arrive at 3 a.m., and a customer who unsubscribed from promotions has not refused their login codes. Profiles can be restricted to one class too, so marketing does not burn the reputation of the transactional domain.
Sending pipeline¶
sequenceDiagram
participant CRM
participant Module as Mailer
participant Queue
participant Provider
CRM->>Module: MailerSendEmail
Module->>Module: address, suppression, duplicate
Module->>Module: assemble content, text part, unsubscribe
Module->>Module: size, attachments, send window
Module->>Queue: PENDING
Queue->>Provider: send (attempt N)
Provider-->>Queue: provider message id → SENT
Provider-->>Module: delivery, bounce, complaint, open, click
Module->>Module: status, suppression, counters
Module-->>CRM: mailer.email.final
Checks that can reject an email for free run before the provider is contacted: address validity, suppression, duplicate window, assembled size, attachment limits, recipient domain, send window. Everything after queuing — attempts, backoff, failover, rate limits — happens without the caller waiting.
Protecting the domain¶
Three mechanisms work together, and each one is documented on its own method page:
- Suppression list — a bounce or complaint from a provider webhook lands there automatically, so the same dead address is never emailed twice.
- Reputation guard — a cron computes the bounce and complaint rates per profile over a rolling window and disables a profile that crosses the threshold (defaults: 5% bounces, 0.1% complaints, with a minimum sample). The profile stops taking part in routing until an administrator enables it again.
- Domain verification — MailerVerifyProfileDomain asks the provider about DKIM and SPF, because an unverified sender domain is invisible from the sending side: the provider reports success while mailbox providers file the mail as spam.
Events¶
| Event | When |
|---|---|
mailer.email.queued |
Email accepted into the queue |
mailer.email.sent |
Provider accepted the email |
mailer.email.final |
Delivered, bounced, complained, rejected, expired or failed |
mailer.email.engagement |
Open or click recorded |
mailer.recipient.unsubscribed |
Recipient unsubscribed |
mailer.profile.paused |
Profile paused by the reputation guard |
mailer.statusChanged |
Notification for the manager UI |