Skip to content

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:

  1. Create the trigger — MailerAddTrigger with event: "customer.password_reset.delivery_requested" and the route condition arguments: { "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.
  2. 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.
  3. Point the template at a provider profile with trackClicks switched 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:

  1. Create the trigger — MailerAddTrigger with event: "customer.email_template.delivery_requested" and the template condition arguments: { "match": { "template": "12" } }. Every subscriber receives every template request, so without the condition the trigger would fire for all of them.
  2. 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] and account — login, currency, group, balance (also as amount), 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_positions and the rest;
  • <field>_utc for every Unix timestamp in the payload, so approved_time also arrives as approved_time_utc, formatted 2026-09-17 09:58:30. Templates have no date formatting, so the string is prepared here and merge gives 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-Unsubscribe and List-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