Email and Error Pages Theming in Drupal

This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production.

This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages.

Most of a site’s theming effort goes into the pages a visitor reaches on a good day. Three kinds of output are rendered on a bad day or outside the browser, and they are often left in their default state until a visitor complains: the maintenance page shown while the site is offline for an update, the error pages for "access denied" and "page not found", and the email messages that Drupal sends for password resets, account approvals and form notifications. A themer owns the look of all three, a site builder configures which pages are used, and an administrator decides how much detail an error is allowed to reveal.

This page was checked against the official documentation and the Drupal core source on 2026-10-30. It is written against Drupal 11.4.x, Drush 13 or 14 and PHP 8.3 or later (see Requirements and hosting for the full baseline). The Drupal 10 line ends on 2026-12-09, so new sites should start on Drupal 11. Drupal 12.0.0 is planned for the week of 2026-12-07; the notes below say where a Drupal 12 change matters (Olivero, Claro and Stable 9 are removed from core there, and a theme that extends one of them as a base theme must be reviewed). The examples belong to the fictitious Meridian Museum site from the sub-section index, whose theme meridian lives in web/themes/custom/meridian (see Starterkit and subthemes).

The page builds on three other pages. How a template is chosen and how preprocess functions fill its variables is explained in Template suggestions and preprocess; the Twig language itself is covered in Twig templates and debugging; and libraries such as an email stylesheet are attached as described in Libraries and assets.

Overview: three special render paths

The three kinds of output share one property: each is produced by a code path that deliberately does less than a normal page request, so the usual theming assumptions (blocks, menus, the full cache) may not hold.

flowchart TD R[Request] --> M{Maintenance mode on<br/>and visitor not exempt?} M -- yes --> MP[maintenance-page.html.twig<br/>HTTP 503] M -- no --> K[Controller or entity route] K -- exception --> X{Status code} X -- 401 403 404 --> E[Subrequest to /system/401 403 404<br/>or the configured custom page] E --> P[page.html.twig with<br/>page--4xx or page--404 suggestion] X -- 5xx --> F[Final exception subscriber<br/>short message, no theme] K -- database down --> O[maintenance-page--offline.html.twig]

The maintenance page and the error pages are rendered by Drupal core and themed in your active (or maintenance) theme. Email is different: core builds a plain-text message from a hook_mail() implementation and hands it to a mail plugin, and HTML email theming is provided by a contributed module. The sections below take them in that order.

The maintenance page

What maintenance mode does

Maintenance mode lets an administrator take the site offline for visitors while staying logged in. It is the right tool while running database updates, importing configuration that changes content types, or restoring a backup. An editor or site builder turns it on at Configuration > Development > Maintenance mode (/admin/config/development/maintenance), ticks the option to put the site into maintenance mode, edits the message and saves. The User Guide’s page on enabling and disabling maintenance mode describes the same steps and suggests clearing the cache if the change does not appear for an anonymous visitor.

Internally, the switch is a single entry in the key-value state system, system.maintenance_mode, not a configuration value. That has three practical consequences:

  • it is not exported with drush config:export, so it never travels between environments by accident;

  • it can be changed from the command line without a web request;

  • it is not versioned, so it is lost if the state table is dropped and re-imported.

The message shown to visitors is a configuration value, system.maintenance:message, whose default text is "@site is currently under maintenance. We should be back shortly. Thank you for your patience." The @site placeholder is replaced with the site name.

# Put the Meridian Museum site into maintenance mode, deploy, and bring it back.
ddev drush maint:set 1
ddev drush deploy
ddev drush maint:set 0

# The same through the state API, which is what maint:set writes.
ddev drush state:set system.maintenance_mode 1 --input-format=integer
ddev drush state:get system.maintenance_mode

maint:set (alias mset) exists in Drush 11.5 and later. Because Drush bootstraps Drupal without going through the kernel request event, it keeps working while the site is in maintenance mode. drush deploy does not enable maintenance mode by itself; wrap it as above if the update must not be visible to visitors.

Who sees the page

The MaintenanceModeSubscriber listens to the kernel request event with a high priority (30). When the state is on and the route applies, it first calls the page-cache kill switch so that the maintenance response is never cached. It then decides whether the visitor is exempt:

  • A user with the permission Use the site in maintenance mode (access site in maintenance mode) is exempt. They browse the site normally and see a status message, "Operating in maintenance mode", with a Go online link when they also have Administer site configuration.

  • Everyone else, including anonymous visitors, gets a response with HTTP status 503 Service Unavailable. A 503 is the correct signal for search engines: the content is temporarily unavailable and should not be dropped from the index. A request whose format is not HTML (a JSON:API or REST client, for example) gets the message as a plain-text 503 instead of a themed page.

  • A route that sets the route option _maintenance_access is never locked (the login form and the update script are examples). Log in at /user/login to reach an exempt account.

Grant the permission sparingly. A role that can use the site in maintenance mode can read content that has not been announced, and a route that is exempt from the lock is also a route that is reachable by a visitor during an update. The Content Editor role of the Meridian Museum does not need it; the Administrator role does.

Reverse proxies and CDNs add their own considerations. A 503 is not cached by Drupal, but Varnish or a CDN may serve a stale copy of a normal page while the origin answers 503, which is usually what you want during a short window. If the window is long, a proxy can add a Retry-After header.

Which theme renders the page

The maintenance page is rendered through _drupal_maintenance_theme() in core/includes/theme.maintenance.inc, a reduced theme initialisation that also serves the installer, the update script and the case where the database is unavailable. Its logic, in order:

  1. During installation, use the theme chosen in the install state. During a database update, use the setting maintenance_theme if present, else the installer’s administration theme. The comment in default.settings.php says the setting does not apply to installation and update pages, so treat it as meant for the maintenance-mode and offline cases.

  2. Otherwise read $settings['maintenance_theme']. If empty, read the default theme from system.theme:default.

  3. If neither can be determined (typically because the database connection failed), fall back to the installer’s theme.

  4. If the chosen theme is not installed, its extension is loaded anyway and the base themes are collected by walking base theme entries, so a theme that is on disk but never enabled can still serve as the maintenance theme.

The setting lives in settings.php, the same file that holds the other per-environment overrides described in settings.php and environments:

<?php

/**
 * Meridian Museum: theme used for the maintenance page and the offline page.
 *
 * The theme only needs to exist on disk. Keep it independent of the database:
 * no blocks, no menus, no views, no entity loads in its templates.
 */
$settings['maintenance_theme'] = 'meridian';

Setting it explicitly is recommended for two reasons. First, when the database is down the default theme cannot be read, so without the setting the fallback theme appears and the page looks unbranded exactly when a visitor is already worried. Second, a custom theme often inherits blocks or regions that need the database; a dedicated, small maintenance theme is more robust. A sub-theme generated from the Starterkit can be used for this purpose: add a second .info.yml file for a minimal theme such as meridian_maintenance that has only a content region and a stylesheet, and point maintenance_theme at it.

The contributed Maintenance Theme project offers a user interface for choosing a theme. Its project page is old, and the settings.php key is the supported way in current core.

Templates and variables

The page is a maintenance_page theme hook. Its default template is core/modules/system/templates/maintenance-page.html.twig; several core themes ship their own versions in their own template directories. A theme overrides it by copying the file into its templates/ directory (and rebuilding the cache, because the theme registry is cached). Variables are prepared by ThemePreprocess::preprocessMaintenancePage() (formerly template_preprocess_maintenance_page()):

  • logo, site_name and site_slogan are filled directly from theme settings and system.site, because the page has no blocks;

  • title is the page title;

  • page holds the regions, of which only content, highlighted, sidebar_first, sidebar_second and footer are rendered by the default template;

  • the library system/maintenance is attached, which gives the page minimal styling.

{#
/**
 * @file
 * Meridian Museum maintenance page.
 *
 * Copy of core's maintenance-page.html.twig with museum branding.
 * Keep it free of anything that needs the database.
 */
#}
<div class="maintenance">
  <header class="maintenance__header">
    {% if logo %}
      <img src="{{ logo }}" alt="{{ 'Meridian Museum'|t }}" width="160" height="48">
    {% endif %}
    <h1 class="maintenance__title">{{ site_name }}</h1>
  </header>

  <main class="maintenance__main" id="main-content">
    {% if title %}
      <h2>{{ title }}</h2>
    {% endif %}
    {{ page.highlighted }}
    {{ page.content }}
    <p>{{ 'The galleries stay open. Tickets can be bought at the desk.'|t }}</p>
  </main>

  {% if page.footer %}
    <footer class="maintenance__footer">{{ page.footer }}</footer>
  {% endif %}
</div>

The maintenance page is a complete document: the HTML wrapper comes from the html template that is rendered around it, so the template above does not need <html> or <body>. Attach styles through the library system, as for any other page. A library declared in the theme’s *.libraries.yml can be attached by a preprocess function:

<?php

/**
 * @file
 * Meridian theme file.
 */

/**
 * Implements hook_preprocess_maintenance_page().
 */
function meridian_preprocess_maintenance_page(array &$variables): void {
  // The library is declared in meridian.libraries.yml as "maintenance".
  $variables['#attached']['library'][] = 'meridian/maintenance';
}

Since 11.2 a module can implement preprocess hooks as class methods with the #[Hook('preprocess_maintenance_page')] attribute, and since 11.3 a theme can do the same and use object-oriented hooks in general (see Hooks and OOP hooks). The procedural form above works in every supported version, which is why this page keeps it for theme code.

The offline template

When Drupal cannot connect to the database, almost everything that depends on configuration fails. A theme can still control what the visitor sees by supplying a more specific template named maintenance-page--offline.html.twig. The suggestion maintenance_page__offline is added by SystemThemeHooks::themeSuggestionsMaintenancePage() (the body of the old system_theme_suggestions_maintenance_page()) when either of two conditions holds:

  • the MAINTENANCE_MODE constant is defined, which is the case for the installer and the update script; or

  • a probe call to path.matcher throws, which happens when the database is unavailable.

A visitor who sees the normal maintenance message because an administrator switched maintenance mode on gets the plain maintenance-page.html.twig; the offline variant is for installer, updater and database failures. The core comment explains the purpose: dead databases show error messages, so supplying this template lets a themer override the page and its content completely.

{#
/**
 * @file
 * maintenance-page--offline.html.twig for the Meridian Museum.
 *
 * Used when the database is unreachable. Static content only.
 */
#}
<div class="maintenance maintenance--offline">
  <h1>{{ 'Meridian Museum'|t }}</h1>
  <p>{{ 'Our website is temporarily unavailable. Opening hours and directions:'|t }}</p>
  <ul>
    <li>{{ 'Tuesday to Sunday, 10:00 to 17:00'|t }}</li>
    <li>{{ '14 Observatory Road'|t }}</li>
  </ul>
  {# Do not render page.content here: it carries the technical error message. #}
</div>

Two cautions apply. The offline template runs with no database, so |t falls back to the source string, and any variable that comes from configuration (the site name, the slogan, the logo path from theme settings) may be empty; write the important facts in the template. And leaving page.content out hides the error message from the visitor, which is good, but it also hides it from the administrator; see the error display levels below and the logs for the technical detail.

Because maintenance and offline pages are static HTML, an operations team often places a copy in the web server’s configuration as a fallback for the case where PHP itself is down:

# Served when PHP-FPM returns 502 or 503 and Drupal cannot answer at all.
error_page 502 503 504 /offline.html;
location = /offline.html {
  root /var/www/meridian/web/themes/custom/meridian/static;
  internal;
}

The 401, 403 and 404 pages

How Drupal answers an access or not-found error

A route that throws AccessDeniedHttpException or NotFoundHttpException ends up in the exception subscribers. For HTML requests three subscribers, in order of priority, are involved:

  1. Fast404ExceptionHtmlSubscriber returns a bare 404 for requests that look like missing static files (below).

  2. CustomPageExceptionHtmlSubscriber (priority -50) checks whether an administrator configured a custom page for the status. The configuration keys are system.site:page.403 and system.site:page.404. If a path is set and the current user can access it, Drupal makes a subrequest to that path and returns its output with the 403 or 404 status code.

  3. DefaultExceptionHtmlSubscriber (priority -128) makes a subrequest to the built-in route /system/401, /system/403, /system/404 or, for other 4xx statuses above 400, /system/4xx. A 400 is deliberately not routed through a subrequest, because the condition that caused it could recur in the subrequest.

Each subrequest renders through the active theme’s page.html.twig, with the normal block layout, which means the error page keeps the site’s header, navigation and footer. Because of that, a 404 page that lists "latest exhibitions" or runs a search form is possible, and a broken block configuration can make the error page itself fail.

Configuring the pages

At Configuration > System > Basic site settings (/admin/config/system/site-information), the Error pages group has two fields, Default 403 (access denied) page and Default 404 (not found) page. They accept an internal path such as /visit/not-found. The Meridian Museum uses a basic page with a search box and links to "Visit", "What’s on" and "Collection".

# config/sync/system.site.yml (excerpt)
page:
  '403': '/access-denied'
  '404': '/page-not-found'
  front: '/node/1'

Setting the page to an internal path has trade-offs:

  • the custom page is a normal node (or view page), so editors can change its content without a deployment, and it can be translated;

  • the path must be accessible to the user who triggered the error, otherwise the subrequest is skipped and the default built-in page appears instead;

  • the status code is still 404 or 403, so search engines do not index the custom page as a normal URL, but make sure the page is not also published under a normal alias that is linked from the site map;

  • a node used as the error page bypasses the page--404.html.twig family of templates below, as the change record warns, because the page rendered is a node route and not /system/404.

Leave the fields empty to use the built-in routes and theme them with templates, which the next section explains.

Templates for error pages

Drupal 8.8 added theme suggestions for the 401, 403 and 404 responses, so a theme can control the complete page markup. SystemThemeHooks::themeSuggestionsPage() (previously system_theme_suggestions_page()) checks the current request’s exception attribute. If it is an HTTP exception with status 401, 403 or 404 it appends two suggestions, the generic one first and the status-specific one last:

Suggestion Template file Applies to

page__4xx

page--4xx.html.twig

401, 403 and 404 together

page__401

page--401.html.twig

Unauthorised

page__403

page--403.html.twig

Access denied

page__404

page--404.html.twig

Not found

Later suggestions win, so page--404.html.twig beats page--4xx.html.twig, which beats page.html.twig. Other 4xx codes (410, 429 and so on) use /system/4xx but receive no special suggestion; to theme them, add a hook_theme_suggestions_page_alter() implementation that reads the status code from the exception, as drupal.org answers recommend.

{# web/themes/custom/meridian/templates/layout/page--404.html.twig #}
<div class="layout-container error-page error-page--404">
  <header role="banner">
    {{ page.header }}
  </header>

  <main role="main" id="main-content">
    <h1>{{ 'We could not find that page'|t }}</h1>
    <p>{{ 'The exhibition or object you are looking for may have moved. Try the collection search.'|t }}</p>
    {{ page.content }}
  </main>

  <footer role="contentinfo">
    {{ page.footer }}
  </footer>
</div>

The page.content region still carries the system message block ("The requested page could not be found."), so keep it unless the template replaces that sentence. A theme that wants the same markup for all three statuses writes only page--4xx.html.twig. Because the template is chosen from the request attributes, it only applies inside the error subrequest; a normal page never receives it.

A preprocess function can add a body class so that CSS can differ per status. The html hook runs for the same subrequest, so it sees the same exception:

<?php

use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

/**
 * Implements hook_preprocess_html().
 */
function meridian_preprocess_html(array &$variables): void {
  $exception = \Drupal::requestStack()->getCurrentRequest()->attributes->get('exception');
  if ($exception instanceof HttpExceptionInterface) {
    $variables['attributes']['class'][] = 'error-page';
    $variables['attributes']['class'][] = 'error-page--' . $exception->getStatusCode();
  }
}

This function does not declare cache metadata for the request attribute. The rendered error page is not stored in the page cache with a normal URL, but it is stored for the failing URL on some configurations, so a reverse proxy that caches 404 responses can keep the page for its TTL. See Performance and caching for how to control that.

Fast 404

Requests for missing images, scripts and other files do not need a fully themed page. Core’s fast 404 feature returns a minimal HTML response for them before the theme and the blocks are loaded. It is configured in the system.performance configuration object, which is why it is set in settings.php as a config override rather than in an administration form:

<?php

/**
 * Fast 404 settings (system.performance:fast_404.*).
 *
 * paths: requests matching this regular expression get the bare 404.
 * exclude_paths: requests matching this are never treated as fast 404, so
 *   image styles can still be generated on first request.
 */
$config['system.performance']['fast_404']['enabled'] = TRUE;
$config['system.performance']['fast_404']['paths'] = '/\.(?:txt|png|gif|jpe?g|css|js|ico|swf|flv|cgi|bat|pl|dll|exe|asp)$/i';
$config['system.performance']['fast_404']['exclude_paths'] = '/\/(?:styles|imagecache)\//';
$config['system.performance']['fast_404']['html'] = '<!DOCTYPE html><html><head><title>404 Not Found</title></head><body><h1>Not Found</h1><p>The requested URL "@path" was not found on this server.</p></body></html>';

The values above are the defaults shipped in system.performance.yml. Two points matter for a themer. A request for /themes/custom/meridian/logo.png that does not exist will never reach page--404.html.twig, which is intended. And a site with page aliases that end in .html or .htm must adjust paths so that real content pages are not treated as files. Anything not matched by paths goes through the full, themed error page, so a crawler requesting thousands of non-existent URLs without extensions can load the site; a CDN or the web server’s location rules are the better defence against that.

Server errors and error display levels

A 5xx response is not routed to a themed page. Uncaught exceptions are handled by the final exception subscriber, which, for HTML, returns a short message, "The website encountered an unexpected error. Try again later." The detail that is added depends on the Error messages to display setting at Configuration > Development > Logging and errors (/admin/config/development/logging), stored as system.logging:error_level:

Option in the form Config value Effect

None

hide

Only the generic message; details go to the log only. The default.

Errors and warnings

some

Shows the error message for errors and warnings on the page.

All messages

all

Shows all PHP notices as well.

All messages, with backtrace information

verbose

Adds the backtrace to uncaught exceptions.

The form itself states that production sites should not display any errors. Showing a backtrace on a public site leaks file paths, class names, query text and sometimes connection details. Set the level per environment with a config override and keep it at hide in production:

<?php

// settings.local.php, development only (DDEV).
$config['system.logging']['error_level'] = 'verbose';

// settings.php, all other environments.
$config['system.logging']['error_level'] = 'hide';

Source: LoggingForm

If a visitor sees the generic message, the real error is in the Recent log messages report (/admin/reports/dblog) or the system log; see Logging, status report and troubleshooting. The final exception subscriber is unthemed on purpose: it must work when the theme layer is itself the failing component. To give visitors a branded 500 page, handle it at the edge (the nginx error_page directive above or the CDN’s custom error page) rather than in Drupal.

Common errors and pitfalls

  • The new template has no effect. The theme registry caches template discovery. Run drush cache:rebuild, and check the file name: maintenance-page--offline.html.twig, page--404.html.twig, with double hyphens, in the active theme (for the maintenance page, the theme named by maintenance_theme).

  • page--404.html.twig is ignored. The 404 page is a node or view configured in the Basic site settings, so the request is not served by /system/404. Empty the field, or add the suggestion to the node’s template instead.

  • The error page itself returns a 500. A block on the error page throws, or the custom page path is itself inaccessible. Test with drush config:set system.site page.404 '' to return to the built-in page.

  • Maintenance mode will not turn off. The state is stored in the database; if the site is unreachable, run drush maint:set 0 or drush state:set system.maintenance_mode 0 --input-format=integer. Remember that Drush works even when the site is offline.

  • The maintenance page looks unstyled. The theme’s libraries need the database to be resolved on some paths. Use a self-contained stylesheet in the maintenance theme and attach it with hook_preprocess_maintenance_page().

  • Search engines index the 404 page. The response must carry status 404. A custom node page that is also linked normally is the usual cause.

  • A node used as error page shows the wrong breadcrumb or title. The subrequest uses the original request context; check the title in the template rather than relying on the node title.

  • Verbose errors in production. Always check system.logging:error_level in the exported configuration for every environment.

Theming HTML email

How core sends mail

Core sends every outgoing message through the mail manager, the service plugin.manager.mail. A module declares its messages with hook_mail(), which fills the subject and body of the message array for a given key, and sends them with $mailManager->mail($module, $key, $to, $langcode, $params). The manager then calls hook_mail_alter() and passes the array to the mail plugin selected in the configuration object system.mail. The default plugin is php_mail, the Default PHP Mailer, which sends plain text through PHP’s mail() function with the headers it builds. Core has no template for the message body, and no way to attach a theme to it: the body is a list of strings that MailFormatHelper wraps and converts.

A Meridian Museum module that notifies a curator when an object is borrowed shows the core API:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;

/**
 * Mail hooks for the Meridian collection.
 */
final class MailHooks {

  use StringTranslationTrait;

  /**
   * Implements hook_mail().
   */
  #[Hook('mail')]
  public function mail(string $key, array &$message, array $params): void {
    if ($key === 'loan_request') {
      $message['subject'] = $this->t('Loan request: @title', ['@title' => $params['title']]);
      $message['body'][] = $this->t('A loan was requested for the object @title.', ['@title' => $params['title']]);
    }
  }

}
// In a service or controller that has the mail manager injected as $this->mailManager.
$this->mailManager->mail('meridian_collection', 'loan_request', 'curator@example.com', 'en', [
  'title' => $object->label(),
]);

hook_mail() is one of the hooks that old tutorials show as a .module function; since 11.1 it can also be a method with the Hook attribute in src/Hook, as above. Both forms work.

Core also contains a Symfony Mailer based mail plugin with the id symfony_mailer, labelled "Symfony mailer (Experimental)" and marked internal. It is a drop-in replacement for the default plugin: delivery is by the Symfony Mailer component, and the transport is a DSN set under mailer_dsn in system.mail. It does not add HTML templating. Configure it with environment variables so that no credentials enter the repository:

<?php

// settings.php: opt in to the experimental core plugin.
$config['system.mail']['interface']['default'] = 'symfony_mailer';
$config['system.mail']['mailer_dsn'] = [
  'scheme' => 'smtp',
  'host' => getenv('MERIDIAN_SMTP_HOST') ?: 'localhost',
  'port' => (int) (getenv('MERIDIAN_SMTP_PORT') ?: 1025),
  'user' => getenv('MERIDIAN_SMTP_USER') ?: NULL,
  'password' => getenv('MERIDIAN_SMTP_PASSWORD') ?: NULL,
  'options' => [],
];

The status of a full core mailer is discussed in contrib issue queues: the Mailer Plus maintainers describe a roadmap and a disagreement about architecture in core, and give no confirmed release in which a supported core mailer will arrive. Treat the contrib module below as the supported way to send themed HTML email on Drupal 11 for now, and re-read its project page before each major upgrade.

Mailer Plus (the symfony_mailer project)

The drupal.org project symfony_mailer (https://www.drupal.org/project/symfony_mailer) is titled Mailer Plus (DSM+) on its project page, and the module name in the 2.x branch is Mailer Plus. It replaces the core mail system with one built on the Symfony Mailer library. At the check date its releases are:

Branch Latest stable Notes

2.x

2.0.2 (2026-07-25)

Many fixes and non-backward-compatible API changes compared with 1.x. Install with composer require 'drupal/symfony_mailer:^2.0'. The 2.x development branch’s .info.yml requires Drupal ^11.3.

1.x

1.6.2 (2025-06-27)

More stable with other modules; does not receive 2.x fixes. Install with composer require 'drupal/symfony_mailer:^1.6'.

The project page lists both branches as working with Drupal ^10.3 || ^11, while the 2.x info file asks for 11.3 or later; check the requirement of the exact release you install. The project is marked minimally maintained and maintenance fixes only, and stable releases are covered by the Drupal security advisory policy.

It provides:

  • HTML mail with a plain-text alternative generated automatically;

  • theming through Twig templates and the library system, with CSS inlined for mail clients;

  • file attachments and embedded images;

  • a configuration UI (the Mailer Policy) that sets subject, body, addresses, transport and theme per email type;

  • multiple transports through the Symfony Mailer library, including load balancing and failover, signing and encryption, and asynchronous sending;

  • three support levels for modules that send mail: compatibility mode (an existing hook_mail() still works), override mode (an EmailBuilder plugin replaces the module’s mail and the old hooks are not called), and native use of the new API.

Install it and the 2.x modules that matter for this page. mailer_override is the module that replaces the Drupal mail system and pulls in the others as dependencies; mailer_policy adds the new API but leaves the core mail system in place, which the documentation says is normally not what you want.

composer require 'drupal/symfony_mailer:^2.0'
ddev drush pm:install mailer_override
ddev drush mailer:override-info   # list which module mail can be overridden
ddev drush mailer:override        # import the existing settings into the policy
ddev drush cache:rebuild

In the 1.x branch, enable the single symfony_mailer module instead. In both branches the Mail System module is not needed, and after overriding, the old per-module email settings (for example the Account settings email templates) are replaced by the Mailer Policy screens. Check every module that sends mail, since hook_mail() and hook_mail_alter() are not called for overridden modules; the project’s supported-modules page lists which core and contrib modules have an EmailBuilder plugin.

flowchart LR S[Module sends mail<br/>type: user, sub-type: password_reset] --> B[EmailBuilder plugin<br/>or hook_mail in compatibility mode] B --> POL[Mailer Policy<br/>subject, body, theme, transport] POL --> T[email--user--password-reset.html.twig<br/>or email.html.twig] T --> W[email-wrap.html.twig] W --> C[CSS inlined from the<br/>email library of the theme] C --> TXT[Plain-text alternative] TXT --> TR[Transport: DSN]

Configuration screens

The 2.x screens are under Configuration > System > Mailer:

  • Mailer Policy (the Mailer page itself) holds the policy. A policy element such as Subject, Body, Theme, Transport or "Send as plain text" can apply to all emails or to one email type. The Body element accepts Twig, so a short notification can be changed without a template file.

  • Transport (/admin/config/system/mailer/transport) lists transports. The default is sendmail. Core Symfony transports have form fields; a third-party transport uses a DSN, such as postmark+api://ACTUAL_API_KEY@default in the project’s own example (the key shown there is a placeholder).

  • Override (under the Mailer page) imports the existing settings for the modules that can be overridden.

A pre-installed policy named Wrap and Convert wraps every email in the email-wrap template and creates the plain-text version. The Mailer Policy form is where a site builder chooses the theme used for mail: by default the active theme is used, falling back to the default theme; add a Theme policy element, globally or per email type, to use another. The Meridian site uses the meridian theme for everything but a smaller meridian_mail theme with only email styles, which keeps email CSS out of the site theme.

For local development the policy can send everything to a catch-all mailbox. Under DDEV, Mailpit is part of the standard environment; set the transport DSN to the Mailpit SMTP port or define a custom sendmail command in settings.local.php with $settings['mailer_sendmail_commands']. For tests or staging sites, the policy’s Skip sending element prevents delivery, and the Reroute Email module (2.3 or later) redirects it, because hook_mail_alter() no longer runs.

Templates

The base template is email.html.twig, whose only variable is body. Suggestions run from most to least specific:

  • email--TYPE--SUBTYPE--ENTITY-ID.html.twig

  • email--TYPE--SUBTYPE.html.twig

  • email--TYPE.html.twig

  • email.html.twig

TYPE corresponds to the module name that older code passes as $module, and SUBTYPE to the message $key. For example email--user--password-reset.html.twig themes the password-reset message. The wrapper template, email-wrap.html.twig, follows the same suggestion pattern and receives these variables: body, is_html (true for HTML, false for the plain-text version), subject, type, sub_type and attributes. Its default markup is a table-based layout with classes email-type-* and email-sub-type-*, which are useful CSS hooks.

{# web/themes/custom/meridian_mail/templates/email-wrap.html.twig #}
{%
  set classes = [
    'email-type-' ~ type|clean_class,
    'email-sub-type-' ~ sub_type|clean_class,
  ]
%}

{% if is_html %}
<html lang="en">
<body>
  <div{{ attributes.addClass(classes) }}>
    <table width="100%" cellpadding="0" cellspacing="0" role="presentation">
      <tr>
        <td class="mail-header">Meridian Museum</td>
      </tr>
      <tr>
        <td class="mail-body">{{ body }}</td>
      </tr>
      <tr>
        <td class="mail-footer">14 Observatory Road</td>
      </tr>
    </table>
  </div>
</body>
</html>
{% else %}
{{ body }}
{% endif %}

A message-specific template overrides only the body:

{# web/themes/custom/meridian_mail/templates/email--user--password-reset.html.twig #}
<h1 class="mail-title">{{ 'Reset your Meridian Museum password'|t }}</h1>
{{ body }}

The default templates ship with the module (templates/email.html.twig and templates/email-wrap.html.twig); copy and edit them in the theme and rebuild the cache. Because email clients ignore most modern HTML and CSS, keep to tables for layout, inline-able CSS, absolute URLs (the module converts relative URLs) and a plain-text alternative. Twig autoescaping applies as usual, so do not use |raw on values that originate from user input; an email is a classic vector for HTML injection into a message that the recipient trusts.

CSS for email

Styles are attached as a library named email in the selected theme. Create the library in the theme’s *.libraries.yml with the stylesheet listed under css and set preprocess: false on it, which has been required since Drupal 10.1.0. The module inlines the CSS into the markup:

# web/themes/custom/meridian_mail/meridian_mail.libraries.yml
email:
  css:
    theme:
      css/mail.css: { preprocess: false }
/* web/themes/custom/meridian_mail/css/mail.css */
.mail-header { background: #123a5e; color: #ffffff; padding: 16px; font-family: Arial, sans-serif; }
.mail-body { padding: 16px; font-family: Arial, sans-serif; font-size: 16px; line-height: 1.5; }
.mail-footer { padding: 16px; color: #555555; font-size: 12px; }
a { color: #0b5cad; }

Do not use CSS variables, @media rules or modern layout (flex, grid) as the only layout mechanism: many mail clients drop them. Keep contrast at the same level as the website (see Accessibility and performance in themes), give images alt text, and set the lang attribute on the root element.

Security of mail

  • Never put a real SMTP password, API key or DSN in an exported configuration file. Use settings.php with environment variables as above, or the secret store of your host. Mailer Plus transports configured in the UI are stored in configuration, so use the DSN form with an environment placeholder or a config override for secrets.

  • A transport DSN of a delivery service often contains the API key itself. Treat it as a secret in the same way.

  • Set SPF, DKIM and DMARC for the sending domain; a themed message that lands in the spam folder is not a success.

  • Do not include one-time login links in messages that are copied to a shared mailbox (a "bcc" policy element).

  • The password-reset message contains a link that is valid for a limited time. Keep the template’s link intact and do not add tracking parameters.

Older books and tutorials

The Drupal 10 books that cover this area predate the current state of both the core mail system and the theme layer. Modernizing Drupal 10 Theme Development (2023) styles the maintenance page and the 403 and 404 pages; its approach of copying maintenance-page.html.twig and the 4xx page templates still works, but the book describes a core of Drupal 10.1 with Olivero and Claro, which Drupal 12 removes, and it shows \Drupal:: calls in .theme code where object-oriented theme hooks are available from 11.3. Drupal 10 Masterclass (2023) treats maintenance mode and mail at the administration level and does not mention the experimental core Symfony Mailer plugin or Mailer Plus 2.x. Drupal 10 Development Cookbook (2023) shows hook_mail() as a .module function and the Swift Mailer or Mail System approach for HTML mail; Swift Mailer is abandoned, and since 11.1 hook_mail() can also be an attribute-based method. For HTML email use Mailer Plus, not Swift Mailer, and follow the project’s own migration guide when upgrading an old site.

Checking the work

  1. Set maintenance_theme, switch maintenance mode on with drush maint:set 1, and open the site in a private window. Expect the themed page and a 503 status in the browser’s network panel (curl -I shows it too).

  2. Make the database unreachable (for example a wrong host in a local settings.local.php override) to see maintenance-page--offline.html.twig.

  3. Request a missing URL such as /does-not-exist and a forbidden one such as /admin/config as an anonymous user; confirm page--404.html.twig and page--403.html.twig or page--4xx.html.twig are used (Twig debug comments list the candidate file names; see the Twig page for how to enable them).

  4. Send a test message by requesting a password reset for a test user in DDEV and read it in Mailpit. Check the HTML and the plain-text part.

  5. Confirm system.logging:error_level is hide in the exported configuration of every non-development environment.

Where this fits in the theme

The maintenance page, error pages and email each live in the theme but run outside the normal page flow, so they benefit from being small, self-contained and tested on their own. Design them with the rest of the theme described in Theming overview and the render pipeline and the regions in Theme info and regions. Security settings that surround them (permission choices, error display, secrets) are collected in Security hardening, and operational use of maintenance mode during deployments is in Cron, queues and maintenance.