Skip to content

Email

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:

  1. Resolve the template from EmailTemplateProviderPoolInterface::getTemplateByIdentifier().
  2. Inject arguments (data the Twig template needs) via $template->addArguments([...]).
  3. Render the HTML body via EmailTemplateRendererInterface::render($template).
  4. Build an EmailMessage and configure to(), from(), subject(), html().
  5. Dispatch the EmailMessage to 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

use Amarant\Framework\Contract\Email\EmailSenderInterface;

$this->emailSender->send($message);

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

Vendor/ModuleName/Configuration/Di.php
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

Vendor/ModuleName/Configuration/Backend/Di.php
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:

Vendor/ModuleName/Configuration/Di.php
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).