The email system is built around three independent extension points: template providers
(declare what templates exist), template previewers (render a template for backoffice preview),
and transport factories (determine how the email is physically delivered). EmailSender ties
the transport layer together; the template rendering and message assembly happen above it, typically
in a dedicated handler class.
Sending an email
The typical flow for a transactional email:
- Resolve the template from
EmailTemplateProviderPoolInterface::getTemplateByIdentifier(). - Inject arguments (data the Twig template needs) via
$template->addArguments([...]). - Render the HTML body via
EmailTemplateRendererInterface::render($template). - Build an
EmailMessageand configureto(),from(),subject(),html(). - Dispatch the
EmailMessageto the message bus for asynchronous delivery.
use Amarant\Framework\Contract\Email\EmailTemplateProviderPoolInterface;
use Amarant\Framework\Contract\Messaging\MessageBusInterface;
use Amarant\Framework\Contract\View\Email\EmailTemplateRendererInterface;
use Amarant\Framework\Email\EmailMessage;
use Amarant\Framework\Messaging\Envelope;
use Symfony\Component\Mime\Address;
public function sendWelcomeEmail(Customer $customer): void
{
$templateId = 'vendor_module_welcome_email';
$template = $this->emailTemplateProviderPool->getTemplateByIdentifier($templateId);
if ($template === null) {
throw new \RuntimeException("Template not found: $templateId");
}
$template->addArguments(['customer' => $customer]);
$html = $this->emailTemplateRenderer->render($template);
$message = new EmailMessage($template->getIdentifier(), $template->getType());
$message
->to($customer->getEmail())
->from(new Address($this->config->getFromEmail(), $this->config->getFromName()))
->subject($template->getSubject())
->html($html);
$this->messageBus->dispatch(Envelope::create($message));
}
A worker consumes the message from the bus and calls EmailSenderInterface::send($message).
Dispatching through the message bus rather than calling send() directly decouples email delivery
from the request cycle.
EmailMessage
EmailMessage extends Symfony's Email and carries two additional metadata fields used by the
template and transport layers:
| Property | Description |
|---|---|
templateId |
Identifier of the template used to generate the body (e.g. 'sales_order_new_order'). |
templateType |
Logical type grouping templates (e.g. 'sales_order'). Used by previewers to select the right renderer. |
All standard Symfony Email methods (to(), from(), subject(), html(), cc(), bcc(),
attach(), etc.) are available.
EmailSenderInterface
EmailSender creates a Symfony Mailer from the active transport, optionally signs the message
with DKIM, and sends it. Errors are logged and re-thrown as EmailException.
DKIM signing is enabled automatically when system/email/smtp/dkim_private_key is configured. The
signer is built lazily on the first send and reused for subsequent calls.
Templates
Template interfaces
| Interface | Extends | Purpose |
|---|---|---|
EmailTemplateInterface |
— | Base. Area, type, identifier, label, subject, arguments, metadata. |
EmailContentTemplateInterface |
EmailTemplateInterface |
Adds templateContent (inline Twig) and templateContentSource (source path, for the editor). |
EmailLayoutTemplateInterface |
EmailTemplateInterface |
Adds layoutHandles and layoutOverrides for layout-based rendering. |
EmailTemplateInterface API
| Method | Description |
|---|---|
getArea(): string |
Application area this template belongs to (AreaInterface::AREA_FRONTEND, etc.). |
getType(): string |
Logical group identifier (e.g. 'sales_order'). |
getIdentifier(): string |
Unique template identifier (e.g. 'sales_order_new_order'). |
getLabel(): string |
Human-readable label shown in the backoffice. |
getSubject(): string\|Stringable |
Email subject line. May contain Twig expressions. |
setSubject(string\|Stringable) |
Override the subject at send time. |
getArguments(): array |
Data passed to the Twig template as variables. |
addArguments(array) |
Merge new key-value pairs into the argument bag. |
replaceArguments(array) |
Replace the entire argument bag. |
removeArguments(array) |
Remove specific keys from the argument bag. |
getAvailableArgumentDescriptions(): array |
Documentation of the arguments this template accepts (key => description). Used by the template editor. |
getMetadata(): array |
Arbitrary extra metadata. |
Concrete template classes
Use EmailContentTemplate for inline Twig content and EmailTemplate when content is not
embedded in PHP (e.g. it is stored in the database and loaded separately).
use Amarant\Framework\Contract\Application\AreaInterface;
use Amarant\Framework\Email\Template\EmailContentTemplate;
new EmailContentTemplate(
area: AreaInterface::AREA_FRONTEND,
type: 'vendor_module_welcome',
identifier: 'vendor_module_welcome_email',
label: 'Welcome email',
subject: '{{ store_name() }}: {{ "Welcome"|trans }}',
templateContent: "{% include '@VendorModule/email/welcome.html.twig' %}",
templateContentSource: '@VendorModule/email/welcome.html.twig',
argumentDescriptions: [
'customer' => 'Customer data model',
]
)
The subject and templateContent fields are rendered as Twig, so Twig functions and filters
are available in both.
Template providers
A template provider registers a set of templates so the rest of the system can look them up by identifier.
Implementing a provider
use Amarant\Framework\Contract\Application\AreaInterface;
use Amarant\Framework\Contract\Email\EmailTemplateInterface;
use Amarant\Framework\Contract\Email\EmailTemplateProviderInterface;
use Amarant\Framework\Email\Template\EmailContentTemplate;
use Override;
final class WelcomeEmailTemplateProvider implements EmailTemplateProviderInterface
{
public const string TEMPLATE_WELCOME = 'vendor_module_welcome_email';
private ?array $cache = null;
#[Override] public function provide(): iterable
{
if ($this->cache === null) {
$this->cache = [
self::TEMPLATE_WELCOME => new EmailContentTemplate(
area: AreaInterface::AREA_FRONTEND,
type: 'vendor_module_welcome',
identifier: self::TEMPLATE_WELCOME,
label: 'Welcome email',
subject: '{{ store_name() }}: {{ "Welcome"|trans }}',
templateContent: "{% include '@VendorModule/email/welcome.html.twig' %}",
templateContentSource: '@VendorModule/email/welcome.html.twig',
argumentDescriptions: ['customer' => 'Customer data model']
),
];
}
return $this->cache;
}
#[Override] public function has(string $templateIdentifier): bool
{
return array_key_exists($templateIdentifier, $this->provide());
}
#[Override] public function get(string $templateIdentifier): ?EmailTemplateInterface
{
return $this->provide()[$templateIdentifier] ?? null;
}
}
Registering a provider
use Amarant\Framework\Contract\Di\ConfigurableContainerInterface;
use Amarant\Framework\Enum\DiEnum;
use Vendor\ModuleName\Email\WelcomeEmailTemplateProvider;
$container->tag(
[WelcomeEmailTemplateProvider::class],
[DiEnum::EMAIL_TEMPLATE_PROVIDER]
);
EmailTemplateProviderPoolInterface::getTemplateByIdentifier() searches all registered providers
in registration order and returns the first match.
Template previewers
A template previewer renders a template in the backoffice email template editor. A class can
implement both EmailTemplateProviderInterface and EmailTemplatePreviewerInterface if it
handles both registration and preview for the same template type.
Implementing a previewer
use Amarant\Framework\Contract\Email\EmailTemplatePreviewerInterface;
use Amarant\Framework\Exception\EmailException;
use Override;
final class WelcomeEmailPreviewer implements EmailTemplatePreviewerInterface
{
#[Override] public function canPreview(array $context = []): bool
{
return ($context[self::CONTEXT_TEMPLATE_TYPE] ?? null) === 'vendor_module_welcome';
}
#[Override] public function preview(array $context = []): string
{
$templateId = $context[self::CONTEXT_TEMPLATE_ID] ?? null;
$content = $context[self::CONTEXT_TEMPLATE_CONTENT] ?? null;
// render and return HTML string
return $this->emailTemplateRenderer->render(...);
}
}
Previewer context keys
| Constant | Key | Description |
|---|---|---|
CONTEXT_TEMPLATE_ID |
'template_id' |
Identifier of the template being previewed. |
CONTEXT_TEMPLATE_TYPE |
'template_type' |
Type of the template (used by canPreview() to match). |
CONTEXT_TEMPLATE_CONTENT |
'template_content' |
Current editor content (may differ from saved template). |
CONTEXT_SUBJECT_IDENTIFIER |
'subject_identifier' |
ID of the entity to use as preview data (e.g. an order ID). |
CONTEXT_SUBJECT |
'subject' |
Raw subject string from the editor. |
CONTEXT_SCOPE |
'scope' |
Scope value for scoped configuration context. |
Registering a previewer
use Amarant\Framework\Contract\Di\ConfigurableContainerInterface;
use Amarant\Framework\Enum\DiEnum;
use Vendor\ModuleName\Email\WelcomeEmailPreviewer;
$container->tag(
[WelcomeEmailPreviewer::class],
[DiEnum::EMAIL_TEMPLATE_PREVIEWER]
);
Note
Previewers are typically only needed in the backend area. Register them in
Configuration/Backend/Di.php.
Transport
EmailTransportFactoryInterface::create() returns a Symfony TransportInterface. The active
transport is selected by the system/email/transport configuration key, which must match the
class name of a tagged factory.
The built-in SmtpTransportFactory reads SMTP settings from EmailConfig and constructs a DSN.
When SMTP is disabled (system/email/smtp/enabled = false) it returns the null transport
(emails are discarded silently).
Adding a custom transport
use Amarant\Framework\Contract\Email\Transport\SelectableEmailTransportFactoryInterface;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Override;
final class SendgridTransportFactory implements SelectableEmailTransportFactoryInterface
{
#[Override] public function create(): TransportInterface
{
// build and return a Symfony TransportInterface
}
#[Override] public function getSelectableOptions(): array
{
return [self::class => 'SendGrid'];
}
}
SelectableEmailTransportFactoryInterface extends EmailTransportFactoryInterface with
getSelectableOptions(): array<value, label>. Implement it when the transport should appear in
the backoffice transport selector.
Register the factory:
use Amarant\Framework\Contract\Di\ConfigurableContainerInterface;
use Amarant\Framework\Enum\DiEnum;
use Vendor\ModuleName\Email\Transport\SendgridTransportFactory;
$container->tag(
[SendgridTransportFactory::class],
[DiEnum::EMAIL_TRANSPORT_FACTORY]
);
To activate it, set system/email/transport to Vendor\ModuleName\Email\Transport\SendgridTransportFactory
(the fully-qualified class name is used as the key).
SMTP configuration
All settings read from the store configuration via EmailConfig. Each method accepts an optional
scope + scopeCode pair for scoped reads.
| Config key | EmailConfig method |
Description |
|---|---|---|
system/email/transport |
getTransport() |
Active transport factory class name. |
system/email/smtp/enabled |
isSmtpEnabled() |
Whether SMTP delivery is enabled. |
system/email/smtp/host |
getSmtpHost() |
SMTP server hostname. |
system/email/smtp/local_domain |
getSmtpLocalDomain() |
EHLO domain. |
system/email/smtp/port |
getSmtpPort() |
SMTP port. |
system/email/smtp/username |
getSmtpUsername() |
Auth username. |
system/email/smtp/password |
getSmtpPassword() |
Auth password. |
system/email/smtp/auto_tls |
isSmtpAutoTls() |
Whether to negotiate TLS automatically. |
system/email/smtp/verify_peer |
isSmtpVerifyPeer() |
Whether to verify the peer TLS certificate. |
system/email/smtp/peer_fingerprint |
getSmtpFingerPrint() |
Expected peer certificate fingerprint. |
system/email/smtp/dkim_private_key |
getSmtpDkimPrivateKey() |
PEM-encoded DKIM private key. Empty = DKIM disabled. |
system/email/smtp/dkim_domain_name |
getSmtpDkimDomainName() |
DKIM signing domain. |
system/email/smtp/dkim_selector |
getSmtpDkimSelector() |
DKIM DNS selector. |
system/email/smtp/dkim_passphrase |
getSmtpDkimPassphrase() |
Passphrase for the DKIM key (optional). |