Sending emails#
In this system an email is never sent directly from a presenter. A manager calls one
of three methods from SendEmailTrait, that method composes the message and — depending on
a setting — either queues it or sends it immediately. The queue is then dispatched by a
cron.
The code: app/Core/Traits/Api/System/Models/Managers/SendEmailTrait.php (templates, the
queue) and MailerTrait.php next to it (mailer choice and the emergency switch; the queue uses
it too).
Three methods#
| Method | When to use it |
|---|---|
sendEmail($langId, $emailKey, $to, $values, …) |
the default choice — a template from the database by key, in the recipient's language |
sendEmailFromText($subject, $body, $from, $to, $values, …) |
text composed in code; no template exists in the database |
sendEmailToAll($emailKey, $recipients, $values, …) |
sending the same template to several recipients |
All of them take $values (placeholders for the template), $files (attachments) and
$replyTos.
A reply-to address is useful where the recipient should write to someone other than the system
Typically for a reply to an advert: the message is sent by the system, but the answer should go to the advertiser.
E-mail from the front end#
The front end module never sends e-mail itself, reads templates or writes to the queue. A
component calls Front\System\…\EmailManager::sendEmail($langId, $emailKey, $to, $values, $replyTos)
and it passes the data to the internal action :Api:System:Email:send
(EmailPresenter::internalActionSend()). The template, rendering and queue write are done by the Api
EmailManager; the outcome comes back as a Result.
| Property | Why |
|---|---|
the action has no actionSend(), from outside it ends with 404 |
with an external route the endpoint would be an open mail relay; FrontApiSurfacePolicyTest guards it |
the front end needs neither :Api:System:Email:getBy nor :Api:System:EmailQueue:save |
both keys stay administrator-only; EmailQueue:save has an external CRUD route |
| attachments are not passed | File objects do not cross the bridge |
empty langId, emailKey or recipient |
INVALID_DATA, nothing is sent |
Do not bring SendEmailTrait back to the front end
The trait reads the template through getBy (administrator-only), so under enforcing RBAC it
finds no template and the form silently sends nothing; the queue write would also need
EmailQueue:save. A flow that needs attachments or its own rules gets its own Api action
(like :Api:System:User:sendNewPassword), not an extra parameter on send.
Where the message goes: the 1/email.sending.mode setting#
This is the single place deciding between the queue and immediate sending:
| Value | Behaviour |
|---|---|
1 |
everything into the queue (the default) |
2 |
into the queue only for non-preferred messages; preferred ones go out immediately |
| anything else | everything is sent immediately, the queue is not used |
Why a queue at all
Sending takes seconds. An order sending two emails would take noticeably longer to process — and during a mail server outage it would fail entirely. This way only the message waits.
The preferred flag only matters in mode 2
The preferred flag distinguishes a registration confirmation or a password reset from
a bulk mailing. With email.sending.mode: 1 even those go into the queue — and they overtake
the newsletter there, not before it.
The queue#
The EmailQueue entity carries the recipient, the sender, the subject, the body, the
attachments, the reply-to addresses and a set of operational fields:
| Field | Meaning |
|---|---|
sent, sendDatetime |
sent and when |
attempts |
the number of attempts |
errorLog |
the last error |
preferred |
priority over bulk mailing |
stopped |
stopped by hand or after exhausting the attempts — the message will not be retried |
newsletter |
a link to the mailing when it is a newsletter |
It is dispatched by the /cron/system/email-queue/send cron — every minute.
Without the cron running, nothing goes out and nobody is told
The queue only grows. The user sees no error, because from the application's point of view the send succeeded — the message was stored, after all. After a deployment verify that the task runs, and then verify it again.
The newsletter is deliberately slow
Thousands of messages within a second look like spam to mail servers. Batching protects the domain's reputation; it is not an unfinished feature.
Retries, stopping and retention#
EmailQueueManager::sendPending() takes a batch of email.queue.batchSize (default 50)
messages that are neither sent nor stopped and whose delay has passed (next_attempt_at):
preferred first, then by id.
| Situation | What is written |
|---|---|
| sent | sent = 1, sendDatetime, errorLog is cleared |
| error | attempts + 1, errorLog, next attempt in 1 min, 5 min, 30 min, 2 h (RETRY_BACKOFF_MINUTES) |
error and attempts reached email.queue.maxAttempts (default 5) |
also stopped = 1; one EMAIL_QUEUE_FAILED bell notification per run, not per message |
With the default settings a message survives an SMTP outage of about two and a half hours.
The cron /cron/system/email-queue/purge-old deletes processed messages (sent or stopped) older
than email.queue.retentionDays (default 30 days, ?days=N overrides it). Bodies carry
personal data and links with tokens. Pending messages and newsletter rows stay: the unique
(newsletter, recipient) index prevents the same address from getting the same newsletter twice.
Do not write the state after sending through the message entity
Sending asks the Api for the SMTP settings, and the message entity may be detached after
sending (a foreign operation boundary, abort() of a rejected save). save() then reports
success but does not
write sent, and every following cron run sends the delivered message again. The state is
therefore written with updateColumns() by id and its Result is checked: an unrecorded
state goes to the mail log as EMAIL_QUEUE_STATE_NOT_RECORDED and to the result as
unrecorded.
Deleting a newsletter row opens a second mailing
Whoever cleans the queue by hand keeps the rows with cms_mod_base_newsletters_newsletter_id
set. Without them the unique index guards nothing and running the mailing again sends the
newsletter to the same addresses.
Templates#
Templates live in the database, keyed by a string, with a text per language and
placeholders replaced from $values.
A template key is never renamed
The code looks the template up by exactly that key
(findBy([new EqualFilter(['key'], [$emailKey])])). After a rename sendEmail() does
not find it and returns a failure — and because the return value often goes unchecked,
the message simply stops arriving.
A missing template translation means an email in another language, not an error
The text is looked up for the recipient's $langId. If there is none, the fallback
applies — the recipient gets the message in the main language.
Numbers in an email: quantities and localization#
A stock quantity comes from the database as '1.500'. Print it in the template with the
{$qty|quantity} filter ("1.5", a whole number "5" without decimals) or pre-format it in the
manager with Quantity::format(); a bare {$qty} shows 1.500, which an English reader takes
for one thousand five hundred. The separator and the price format are decided by the
localization the render runs in:
| Recipient | Language and localization |
|---|---|
| a user or an order | the language and localization of the recipient (order, document) |
an address from the settings (e.g. stock.lowStock.email) |
the default language and the default localization of the website; wrap the render in PriceFormatContext::runWith($localization, $langId, fn () => …) so that \|quantity and \|priceFormat in the template use the same localization as the numbers pre-formatted in PHP |
A parameter with a link to a sign-in-free action#
If an email should carry a link that does exactly one thing without signing in (mark as read),
put it in as a placeholder with the whole address ('markReadLink' =>
$presenter->link('//:…')) and wrap it in the template in the condition
{if !empty($markReadLink)}…{/if}; older records do not have the link. Operators edit
templates, so do not overwrite the new part, only insert the block, and the SQL must be
idempotent (LOCATE('markReadLink', body) = 0). The whole pattern is in
A link with a key, no sign-in.
Pass the currency to the template with its translations
The template priceFormat:$currency reads getTranslation($langId)->getUnitLabel().
emailManager->sendEmail() may run after the identity map was emptied (a foreign operation
boundary, abort()), so translations would be loaded lazily only at render on a
detached currency and the queue flush would fail on
"A new entity was found through the relationship CurrencyText#parent". So give the currency
find($id, ['translations']).
The site address in links#
A link that leaves the request (an email, a sitemap, the product feed, og:url) must not
be built from the Host header: a forged Host would put a foreign domain into an email
to another person, and an email from cron would depend on how cron calls the site. The site
address is therefore supplied by a single service, App\Core\Http\SiteBaseUrl, based on
the site.baseUrl setting (System module, Nastavení webu → Odesílání e-mailů, see
Settings).
| State | Result |
|---|---|
site.baseUrl filled in |
it always applies; the form is http(s)://host[:port][/path] without ?, # and credentials, a trailing slash is cut off on save |
| empty | the address of the request (scheme, host, port, base path), that is the previous behaviour |
| empty and no request (CLI) | ''; absolutize() returns the input unchanged, nothing is thrown |
In a presenter or a manager with a presenter build links for an email through
PresenterContext::absoluteLink(':Front:Bazaar:Advert:detail', $args) (the destination
without the leading //); convert a ready absolute address with
PresenterContext::absolutize(). The router still holds the language, the slug and the
persistent parameters, the service only replaces the start of the address. The safety net
for all messages is SiteBaseUrl::rewriteRequestBase() over the rendered body in
SendEmailTrait. Do not use the old $presenter->link('//:…') pattern in an email any
more.
The website pages are not protected by this
site.baseUrl changes only links meant to be sent or published. It neither redirects
to the canonical host nor checks Host for ordinary pages; that is the web server's
job (see Robots, robots.txt and sitemaps).
Procedure: adding a new email#
- Create the template in the administration (System → E-mails) and give it a key that does not exist yet.
- Fill in the text for every language — not just the main one.
- Call
sendEmail()from a manager, not from a presenter:
$this->sendEmail(
langId: $user->getLanguageId(),
emailKey: 'order.confirmation',
toEmailAddrr: $order->getEmail(),
values: ['orderNumber' => $order->getNumber()],
);
- Call it from
completedSave/completedDelete, that is, after the commit. When the manager can run nested inside another operation, wrap the send in$this->service->afterCommit(fn () => …)so that the email leaves only after the commit of the outermost transaction (see Manager lifecycle). - Evaluate the result — a failure means the message was not even queued.
- Verify on the development machine where the message actually went (see below).
An email in afterSave goes out even when the transaction is rolled back
afterSave runs inside the transaction. The message about a created order is sent,
the order is rolled back — and the customer holds a confirmation of something that does
not exist. Irreversible effects belong after the commit, see
Manager lifecycle.
Fields are NOT filtered by audience in a cron
The dispatch runs outside a request, so #[FieldAccess] does not apply — deliberately,
since an activation email needs activationKey. But it means what ends up in the
message body is the responsibility of the template's author and of $values.
Newsletter subscription addresses#
A subscription address is created and guarded by NewsletterAddressManager (Base).
Activation follows the setting 2/newsletter.address.approval.auto (Site settings →
Komentáře, hodnocení a newsletter; when the row is missing, the address waits for
e-mail confirmation).
| Situation | What happens |
|---|---|
| a new address without auto approval | an inactive row, an activation key, the activation email only in afterSave() after a successful insert |
| subscribing an address that already exists (confirmed or not) | always USER_ALREADY_EXISTS; an unconfirmed one gets the activation again |
| an edit in the administration | an email collision is looked up only against other rows; activation, key and insert date stay, no email is sent |
"Resend activation" (resendActivation()) |
only an inactive address; a new key, the old link stops working; when the email cannot be sent, the previous key is restored |
Every activation email (new address, repeated subscription and resend) is counted by
isActivationMailAllowed() per IP and per address
(Content creation quota); the administrator is exempt. The action
resendActivation has its own key :Api:Base:NewsletterAddress:resendActivation — without
its privilege row RBAC silently denies it. The NEWSLETTER_ADDRESS_NEW notification is
raised in the Api NewsletterAddressPresenter::internalActionSave(), only for a new address and
outside the administration.
A public subscription must not reveal the state of an address
A confirmed and an unconfirmed address return the same code. Two different codes would be translated by the front into two different messages and anyone could check whether a foreign address is subscribed.
The duplicate check must skip the edited row
On an edit findBy(email) also finds the edited address itself. Without comparing the
id, every save of an existing address in the administration would fail and nothing
would be saved.
The emergency sending switch#
params.1.mail.blocked: true in config/Shared/local.neon stops all mail without a
deployment and independently of the database (for instance when a robot finds a form). It is
checked by MailerTrait::initializeMailer(), through which every send passes, the queue cron
included, so the email.sending.mode setting in the administration cannot override it.
| Where | What happens |
|---|---|
| immediate send | App\Core\Mail\NullMailer replaces the mailer: it drops the message, reports success and writes a line to log/mail-blocked.log (recipient count and address fingerprints, not the addresses or the subject) |
| queue cron | EmailQueueManager::sendPending() returns at once (blocked: true), waiting messages stay in the queue |
| queueing | keeps running, messages pile up in cms_system_email_queue |
Procedure: switching the safeguard on and off#
- Set
blocked: true(andreason) in the server'slocal.neon. - Delete
temp/cache/nette.configurator, otherwise the old compiled container keeps running. - Check in
log/mail-blocked.logthat emails are being dropped. - Before switching it off, go through
cms_system_email_queueand delete messages that must not go out. - Set
blocked: falseback and deletetemp/cache/nette.configuratoragain.
The safeguard reports success on purpose
Registration, orders or password reset build their own error messages and rollbacks on
a failed send. A safeguard that returned an error would block those operations too.
That is why the application reports nothing and the only evidence is
log/mail-blocked.log.
After switching it off, everything that piled up goes out
The queue keeps accepting messages while the safeguard is on and the cron sends them all at once afterwards. When you switch it on because of a robot, clean the queue before switching it off.
The development environment#
A copy of the production database holds real addresses
If you develop on a copy of production data (or even on the production database), a test
mailing can reach real customers. Before sending anything, check the value of
1/email.sending.mode and the contents of the queue — and for a bulk mailing check the
recipient list too.
Where to look#
| I want | Where |
|---|---|
| the three sending methods | app/Core/Traits/Api/System/Models/Managers/SendEmailTrait.php |
| e-mail from the front end | app/UI/Front/System/Models/Managers/EmailManager.php → app/UI/Api/System/Presenters/EmailPresenter.php |
| the queue-versus-immediate decision | SendEmailTrait::sendEmailFromText() (email.sending.mode) |
| the mailer choice (sender SMTP, default SMTP, sendmail) and the safeguard | app/Core/Traits/Api/System/Models/Managers/MailerTrait.php |
| the safeguard in the configuration | config/Shared/email.neon → mail.blocked |
| the queue entity | app/Core/Base/Shared/System/Models/Entities/EmailQueue.php |
| the dispatch cron | app/UI/Cron/System/Presenters/EmailQueuePresenter.php |
| managing the templates | the administration → System → E-mails |
Follow-up chapters: Manager lifecycle · Cron presenters · Scheduled tasks · Translations