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 (the Api variant)
and app/Core/Traits/Shared/System/Models/Managers/SendEmailTrait.php (the shared one).
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.
Where the message goes: the 1/emailSending 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 emailSending: 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 — 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.
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.
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. - 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.
The development environment#
Dev and production share a database
A test mailing can reach real customers. Before sending anything, check the value of
1/emailSending 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 |
| the queue-versus-immediate decision | app/Core/Traits/Shared/System/Models/Managers/SendEmailTrait.php |
| 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