Transactional Emails
How order emails are registered, rendered, queued and sent, and how to build one without mailing a real customer.
TransportBuilder::setTemplateIdentifier(string $id)->setTemplateOptions(array $options)->setTemplateVars(array $vars)->setFromByScope(string $identity, int $scopeId)->addTo(string $email, string $name)->getTransport()->sendMessage()A transactional email is a template rendered with a specific entity and sent to one person. Magento keeps the template in your module as a file, lets a merchant override it in the admin, and sends it through one builder.
Registering a template
A template is declared in etc/email_templates.xml with six required attributes: id, label, file, type (html or text), module and area (frontend or adminhtml). The file is relative to view/<area>/email/ in that module. The declaration is cached, so run bin/magento cache:flush after adding it.
Sending one
Magento\Framework\Mail\Template\TransportBuilder is the single entry point. You set the template id, the template options (area and store), the template variables, the sender and the recipient, then call getTransport() and sendMessage(). Use setFromByScope($identity, $storeId); setFrom() has been deprecated since magento/framework 102.0.1, which shipped with Magento 2.3.1, because it ignores the store scope. The identity is one of general, sales, support, custom1 or custom2, resolved from trans_email/ident_*.
Variables
Templates use directives: {{var order.increment_id}}, {{trans "Thank you"}}, {{layout handle="..."}}. Since 2.3.4 variables are resolved in strict mode, which means only scalars and arrays come back, and a getter call is rewritten to a data lookup.
Queueing and testing
Set sales_email/general/async_sending to 1 and order, invoice, shipment and credit memo emails are stored instead of sent, then flushed by the cron jobs sales_send_order_emails and its siblings. To build an email without mailing anyone, set system/smtp/disable to 1.
Example
<?php
// app/code/Vendor/OrderOps/Model/Email/PickupReadyNotifier.php
declare(strict_types=1);
namespace Vendor\OrderOps\Model\Email;
use Magento\Framework\App\Area;
use Magento\Framework\Exception\MailException;
use Magento\Framework\Mail\Template\TransportBuilder;
use Magento\Sales\Api\Data\OrderInterface;
use Magento\Store\Model\StoreManagerInterface;
class PickupReadyNotifier
{
private const TEMPLATE_ID = 'vendor_orderops_pickup_ready';
public function __construct(
private readonly TransportBuilder $transportBuilder,
private readonly StoreManagerInterface $storeManager
) {
}
/**
* @throws MailException
*/
public function notify(OrderInterface $order): void
{
$storeId = (int)$order->getStoreId();
$name = trim($order->getCustomerFirstname() . ' ' . $order->getCustomerLastname());
$transport = $this->transportBuilder
->setTemplateIdentifier(self::TEMPLATE_ID)
->setTemplateOptions(['area' => Area::AREA_FRONTEND, 'store' => $storeId])
->setTemplateVars([
'order' => $order,
'store' => $this->storeManager->getStore($storeId),
'increment_id' => $order->getIncrementId(),
])
->setFromByScope('sales', $storeId)
->addTo($order->getCustomerEmail(), $name)
->getTransport();
$transport->sendMessage();
}
}
// Sends one message immediately. 'store' must be in both the template
// options and the variables: the options pick the right store's override,
// the variable is what {{store url=...}} needs to build links.When to use it
- Email a customer when their click-and-collect parcel reaches the shop.
- Stop a staging environment from mailing real customers while you test checkout.
- Work out why an order confirmation shows a blank order number.
- Move order emails out of the checkout request so placing an order stops timing out.
More examples
Declaring the template and writing it
Reach for this the moment you need an email that is not one of the ones Magento ships. All six attributes are required by the XSD, and the two that go wrong most often are area and module: get area wrong and the template renders with the admin theme's styles, get module wrong and Magento cannot find the file at all. The declaration is held in the config cache, which is why nothing changes until cache:flush.
<!-- app/code/Vendor/OrderOps/etc/email_templates.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Email:etc/email_templates.xsd">
<template id="vendor_orderops_pickup_ready"
label="Order ready for pickup"
file="pickup_ready.html"
type="html"
module="Vendor_OrderOps"
area="frontend"/>
</config>
<!--
The file itself lives at:
app/code/Vendor/OrderOps/view/frontend/email/pickup_ready.html
After adding both files: bin/magento cache:flush
The template id now appears in the Template dropdown under
Marketing > Email Templates when a merchant creates an override, and in any
system.xml field that uses Magento\Config\Model\Config\Source\Email\Template.
-->The template body, with the strict-mode variable pairing
The two {{var}} forms above print the same thing. Since 2.3.4 Magento resolves template variables in strict mode: a getter directive is converted to a getData() lookup on the matching snake_case key, so the dotted shorthand is the honest way to write it and the getter form is what you will find in older templates. The @vars block is only documentation for the admin's variable picker, and @subject is where the subject line lives. If a variable renders as nothing, it is almost always because the value is an object rather than a scalar.
<!-- app/code/Vendor/OrderOps/view/frontend/email/pickup_ready.html -->
<!--@subject {{trans "Order %increment_id is ready to collect" increment_id=$increment_id}} @-->
<!--@vars {
"var increment_id":"Order Number",
"var order.customer_firstname":"Customer First Name"
} @-->
{{template config_path="design/email/header_template"}}
<p>{{trans "Hello %name," name=$order.customer_firstname}}</p>
<p>
{{trans "Order"}}
<!-- Shorthand: the data key. -->
{{var order.increment_id}}
<!-- Longhand: in strict mode this resolves to the SAME data key, -->
<!-- because getIncrementId() is rewritten to getData('increment_id'). -->
{{var order.getIncrementId()}}
{{trans "is waiting at the collection desk."}}
</p>
<p><a href="{{store url='sales/order/history'}}">{{trans "View your orders"}}</a></p>
{{template config_path="design/email/footer_template"}}Queueing, and switching sending off while you work
Use the first block on every development machine, before the first order is placed. system/smtp/disable builds and renders the message, then throws it away, so template errors still surface while no customer receives anything. Asynchronous sending is the setting to reach for when checkout feels slow: with it on, the order row is written with send_email = 1 and email_sent = 0, and the cron job sends it in the background. The catch is that it depends entirely on cron, so on a machine where cron is not running the emails simply never leave.
# Build and render emails without mailing anybody on a dev box.
bin/magento config:set system/smtp/disable 1
bin/magento cache:flush
# Or point every message at a local catcher instead of dropping it.
bin/magento config:set system/smtp/transport smtp
bin/magento config:set system/smtp/host mailhog
bin/magento config:set system/smtp/port 1025
bin/magento cache:flush
# Queue sales emails instead of sending them during checkout.
bin/magento config:set sales_email/general/async_sending 1
bin/magento config:set sales_email/general/sending_limit 50
bin/magento cache:flush
# Flush the queue by hand while testing.
bin/magento cron:run --group=default
# The four jobs that drain it:
# sales_send_order_emails
# sales_send_order_invoice_emails
# sales_send_order_shipment_emails
# sales_send_order_creditmemo_emails
Discussion