Skip to content
V
For developers
Architecture, conventions, the core and security
Core / Sending e-mail

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

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']).

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#

  1. Create the template in the administration (System → E-mails) and give it a key that does not exist yet.
  2. Fill in the text for every language — not just the main one.
  3. Call sendEmail() from a manager, not from a presenter:
$this->sendEmail(
    langId: $user->getLanguageId(),
    emailKey: 'order.confirmation',
    toEmailAddrr: $order->getEmail(),
    values: ['orderNumber' => $order->getNumber()],
);
  1. 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).
  2. Evaluate the result — a failure means the message was not even queued.
  3. 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.

parameters:
    params:
        1:
            mail:
                blocked: true
                reason: 'robot on the contact form'   # goes to the log
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#

  1. Set blocked: true (and reason) in the server's local.neon.
  2. Delete temp/cache/nette.configurator, otherwise the old compiled container keeps running.
  3. Check in log/mail-blocked.log that emails are being dropped.
  4. Before switching it off, go through cms_system_email_queue and delete messages that must not go out.
  5. Set blocked: false back and delete temp/cache/nette.configurator again.

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

Cookie settings