Feature: #91724 - Introduce TemplatedEmailFactory for centralized email creation 

See forge#91724

Description 

A new TemplatedEmailFactory class has been introduced that provides centralized creation of FluidEmail instances.

The factory provides three methods for different use cases:

create()
For backend/CLI contexts (login notifications, scheduler tasks, install tool) where only global configuration $GLOBALS['TYPO3_CONF_VARS']['MAIL'][...] is used.
createFromRequest()
For frontend contexts (form submissions, felogin) where site-specific email templates should be applied. Merges site settings typo3/email with global configuration $GLOBALS['TYPO3_CONF_VARS']['MAIL'][...] .
createWithOverrides()

For extensions that need to provide custom template paths merged on top of the base configuration, taking an optional request context into evaluation. Two cases of template resolution are then possible (ordered by priority):

  • Request without site attribute: 1. Provided override arguments -> 2. global config
  • Request with site attribute: 1. Provided override arguments -> 2. Site settings -> 3. global config

Note you can also utilize the numerical priority of template paths so that site settings with a higher priority number could "win" the order battle of a lower-number in the override argument paths.

Site settings 

A new site set typo3/email is available in EXT:core that defines the following settings. These are applied automatically when a request with a site attribute is passed to createFromRequest() or createWithOverrides(). This means extensions running in a frontend context (such as EXT:form email finishers) benefit from site-specific email configuration:

email.format
The email format to use (html, plain, both). If empty, the global configuration is used.
email.templateRootPaths
Array of paths to email templates. These are merged with the global mail template paths.
email.layoutRootPaths
Array of paths to email layouts. These are merged with the global mail layout paths.
email.partialRootPaths
Array of paths to email partials. These are merged with the global mail partial paths.

Usage 

Frontend usage (site-aware) 

For frontend contexts where site-specific templates are desired, use createFromRequest(). Include the typo3/email site set in your site configuration:

config/sites/my-site/config.yaml
dependencies:
  - typo3/email

settings:
  email:
    templateRootPaths:
      100: 'EXT:my_sitepackage/Resources/Private/Templates/Email/'
    layoutRootPaths:
      100: 'EXT:my_sitepackage/Resources/Private/Layouts/Email/'
    format: 'html'
Copied!
EXT:my_extension/Classes/Service/MyFrontendEmailService.php
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Mail\TemplatedEmailFactory;
use TYPO3\CMS\Core\Mail\MailerInterface;

final class MyFrontendEmailService
{
    public function __construct(
        private readonly TemplatedEmailFactory $templatedEmailFactory,
        private readonly MailerInterface $mailer,
    ) {}

    public function sendEmail(ServerRequestInterface $request): void
    {
        // Uses site-specific template paths if configured
        $email = $this->templatedEmailFactory->createFromRequest($request);
        $email
            ->setTemplate('MyTemplate')
            ->to('recipient@example.com')
            ->from('sender@example.com')
            ->subject('My Subject')
            ->assign('name', 'World');

        $this->mailer->send($email);
    }
}
Copied!

Backend/CLI usage 

For backend contexts where no site-specific templates are needed, use create():

EXT:my_extension/Classes/Service/MyBackendEmailService.php
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Mail\TemplatedEmailFactory;
use TYPO3\CMS\Core\Mail\MailerInterface;

final class MyBackendEmailService
{
    public function __construct(
        private readonly TemplatedEmailFactory $templatedEmailFactory,
        private readonly MailerInterface $mailer,
    ) {}

    public function sendNotification(?ServerRequestInterface $request = null): void
    {
        // Uses only global $GLOBALS['TYPO3_CONF_VARS']['MAIL'] configuration
        $email = $this->templatedEmailFactory->create($request);
        $email
            ->setTemplate('SystemNotification')
            ->to('admin@example.com')
            ->from('system@example.com')
            ->subject('System Notification');

        $this->mailer->send($email);
    }
}
Copied!

Custom template path overrides 

For extensions that need their own email templates merged with global configuration, use createWithOverrides():

EXT:my_extension/Classes/Task/MySchedulerTask.php
use TYPO3\CMS\Core\Mail\TemplatedEmailFactory;
use TYPO3\CMS\Core\Mail\MailerInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Scheduler\Task\AbstractTask;

final class MySchedulerTask extends AbstractTask
{
    public function sendReport(): void
    {
        // This example showcases how to use this when
        // constructor-based Dependency Injection is blocked, like in the case of
        // AbstractTask (EXT:scheduler). Always use DI, where possible.
        $mailer = GeneralUtility::makeInstance(MailerInterface::class);
        $templatedEmailFactory = GeneralUtility::makeInstance(TemplatedEmailFactory::class);

        // Merge extension-specific paths with global configuration.
        // Note, if you do NOT pass a `$request` argument here, no site context will be evaluated.
        // You may want to check for `$GLOBALS['TYPO3_REQUEST']` in case you want this fallback,
        // or a custom created request object.
        $email = $templatedEmailFactory->createWithOverrides(
            templateRootPaths: [20 => 'EXT:my_extension/Resources/Private/Templates/Email/'],
            layoutRootPaths: [20 => 'EXT:my_extension/Resources/Private/Layouts/'],
        );
        $email
            ->setTemplate('Report')
            ->to('admin@example.com')
            ->from('system@example.com')
            ->subject('Scheduled Report');

        $mailer->send($email);
    }
}
Copied!

Core migrations 

All core extensions that send emails have been migrated to use the TemplatedEmailFactory. This includes:

  • EXT:form - Email finishers now use createWithOverrides() with the request from the form runtime, so site-specific email settings are applied automatically.
  • EXT:felogin - Password recovery emails now use createWithOverrides(), making them site-aware. The method RecoveryConfiguration::getMailTemplatePaths() has been removed as template path resolution is now handled by the factory.
  • EXT:backend - Login notifications, failed login/MFA attempt notifications, and password reset emails use create().
  • EXT:install - Test email sending uses create().
  • EXT:workspaces - Stage change notifications use createWithOverrides().
  • EXT:linkvalidator - Broken link report emails use createWithOverrides().
  • EXT:reports - System status emails use create().

Impact 

Extensions that send emails are encouraged to use the TemplatedEmailFactory to create FluidEmail instances instead of directly instantiating them. When a request with a site attribute is passed, template paths and format from the typo3/email site set are applied automatically. The merge priority is (highest priority wins):

  1. Global $GLOBALS['TYPO3_CONF_VARS']['MAIL'] paths (base)
  2. Site settings from typo3/email, when a site-based request is available, and site settings are applied.
  3. Caller-provided override paths, when using createWithOverrides().