Magic HTML Mail (html_mail) sends Drupal email as HTML from one themeable Twig template (html-mail.html.twig), with a plain-text alternative. Any module's hook_mail() message gets an HTML version without changes. Sending modules can add optional hints (heading, one-time code, button, summary card). Submodules send through Drupal Symfony Mailer or Amazon SES. It requires Drupal 10.3 or 11.

Similar projects

htmlmail and mimemail work through Mail System with per-module templates or MIME/attachment handling. This module uses a single template and a hints API, and sits on top of Symfony Mailer or Amazon SES. The project page explains the differences.

Reviews done

Project link

https://www.drupal.org/project/html_mail

Comments

jamestarleton created an issue. See original summary.

jamestarleton’s picture

Title: [D10] [D11] Magic HTML Mail » [1.0.x] Magic HTML Mail
vishal.kadam’s picture

Issue summary: View changes
avpaderno’s picture

Issue summary: View changes

Thank you for applying!

Before giving links helpful to understand how the review process works, what to expect from a review, and what to do to avoid a review takes more time than needed, I would like to thank all the reviewers for the work they do.
These applications are volunters-driven, which also means it is not possible to predict when an application will be marked fixed and the applicant will get the permission to opt projects into security advisory policy. While we aim to make an application as quick as possible, it is also important for us that more people review the project used for an application. In this way, we make sure applications do not miss some important points that should be instead reported.
Applications are not meant to be complete debugging sessions that eliminate every existing bug, though. I apologize if sometimes applications seem to go into too-detailed reviews.

Please read Review process for security advisory coverage: What to expect for more details and Security advisory coverage application checklist to understand what reviewers look for. Tips for ensuring a smooth review gives some hints for a smoother review.

The important notes are the following.

  • If you have not done it yet, you should enable GitLab CI for the project and fix the PHP_CodeSniffer errors/warnings it reports.
  • For the time this application is open, only your commits are allowed.
  • The purpose of this application is giving you a new drupal.org role that allows you to opt projects into security advisory coverage, either projects you already created, or projects you will create. The project status will not be changed by this application; once this application is closed, you will be able to change the project status from Not covered to Opt into security advisory coverage. This is possible only 14 days after the project is created.

    Keep in mind that once the project is opted into security advisory coverage, only Security Team members may change coverage.
  • Only the person who created the application will get the permission to opt projects into security advisory coverage. No other person will get the same permission from the same application; that applies also to co-maintainers/maintainers of the project used for the application.
  • We only accept an application per user. If you change your mind about the project to use for this application, or it is necessary to use a different project for the application, please update the issue summary with the link to the correct project and the issue title with the project name and the branch to review.

To the reviewers

Please read How to review security advisory coverage applications, Application workflow, What to cover in an application review, and Tools to use for reviews.

The important notes are the following.

  • It is preferable to wait for a project moderator before posting the first comment on newly created applications. Project moderators will do some preliminary checks that are necessary before any change on the project files is suggested.
  • For the tools to use for reviewing a project, see Tools to use for reviews.
  • It may be best to have the applicant fix things before further reviews and avoid doing a review when another review is pending.

For new reviewers, I would also suggest to first read In which way the issue queue for coverage applications is different from other project queues.

avpaderno’s picture

Issue summary: View changes
vishal.kadam’s picture

Status: Needs review » Needs work

1. FILE: html_mail.module, modules/html_mail_symfony/html_mail_symfony.module

For a new module that aims to be compatible with Drupal 10 and Drupal 11, I would rather implement hooks as class methods as described in Support for object oriented hook implementations using autowired services.

2. FILE: src/Form/SettingsForm.php

With Drupal 10 and Drupal 11, there is no longer need to use #default_value for each form element, when the parent class is ConfigFormBase: It is sufficient to use #config_target, as in the following code.

    $form['image_toolkit'] = [
      '#type' => 'radios',
      '#title' => $this->t('Select an image processing toolkit'),
      '#config_target' => 'system.image:toolkit',
      '#options' => [],
    ];

Using that code, it is no longer needed to save the configuration values in the form submission handler: The parent class will take care of that.

jamestarleton’s picture

Thank you for the review, @vishal.kadam. Both points are addressed in commit 94c36e7 on 1.0.x.

1. OOP hooks

  • The hooks are now methods of Drupal\html_mail\Hook\HtmlMailHooks (help, mail, theme) and Drupal\html_mail_symfony\Hook\HtmlMailSymfonyHooks (mail_alter, mailer_init, form_html_mail_settings_alter). They use #[Hook] attributes and constructor-injected (autowired) services.
  • The modules support ^10.3 || ^11. So the .module files keep thin procedural functions marked #[LegacyHook], which delegate to the hook classes. The hook classes are also registered as autowired services, for Drupal versions earlier than 11.1. This follows the backward-compatibility pattern in the change record https://www.drupal.org/node/3442349.
  • The tests pass on Drupal 10.3 (the legacy path) as well as on Drupal 11.

2. #config_target in the settings form

  • Every setting in SettingsForm now uses #config_target, with no #default_value. The custom submitForm() is gone, and ConfigFormBase loads, validates and saves the values. The cleanup the old submit handler did (trimming, a site URL with no trailing slash, lowercase colors, an integer logo width) moved to static toConfig callbacks. They are static so that form caching keeps working.
  • The field that html_mail_symfony adds through the form alter also uses #config_target, on html_mail_symfony.settings:modules. Static fromConfig/toConfig callbacks convert between the textarea and the sequence, so the extra submit handler is gone as well.
  • With config validation now running, html_mail.settings needed a langcode, because it contains translatable values. It was added to the install config, and a post-update function adds it on existing sites.
  • A functional test was added for the Symfony Mailer settings field.

PHPCS (Drupal, DrupalPractice) and PHPStan level 8 report no errors, and tests pass.

jamestarleton’s picture

Status: Needs work » Needs review
avpaderno’s picture

Issue tags: -PAreview: review bonus
shaker-gspikes’s picture

Status: Needs review » Needs work

Manual review of 1.0.x at 215bbc4 (14 October 2026). phpcs (Drupal, DrupalPractice) is clean locally and all commits are by the applicant. The changes from #6 are in: the hooks are methods of HtmlMailHooks / HtmlMailSymfonyHooks with #[LegacyHook] wrappers, and the settings form uses #config_target.

The output handling is careful: Twig autoescaping is never turned off, URL hints go through UrlHelper::stripDangerousProtocols(), HTML hints through Xss::filter() with an allow-list, the footer setting through Xss::filterAdmin(), colours are validated by the form element and a color_hex schema type, nothing is fetched from URLs or files, and the settings route uses a restrict access permission. The points below are about the mail contract and the submodules.

1. Body lines are always treated as plain text (src/HtmlMailRenderer.php, paragraphs() and build()): every body line is cast to a string, split into paragraphs and passed through Html::escape() for the HTML part, and the text part is MailFormatHelper::wrapMail() of the same strings, without htmlToText(). MailInterface::format() documents that body lines can be MarkupInterface, and core's PhpMail::format() runs MailFormatHelper::htmlToText() over the whole body. Two common cases break:
- core's account emails: user_mail() builds the body with $token_service->replace(), which HTML-escapes token values. With a site name such as "O'Brien Dental", [site:name] becomes O'Brien Dental; core's mail plugin decodes that, while this module shows the entity in the plain-text part and double-escapes it in the HTML part. user is in html_mail_symfony's default list of modules that always get the template, so this affects the default setup.
- modules that send rendered markup (for example the contact module's message body) show literal tags in both parts.
Converting with MailFormatHelper::htmlToText(implode("\n\n", $body)) first, and building the HTML paragraphs from that text (or keeping MarkupInterface lines as filtered markup), would match what core does.

2. html_mail_ses needs amazon_ses 3.1 but does not say so (modules/html_mail_ses/html_mail_ses.info.yml): HtmlMailSes::mail() uses $this->configFactory, $this->messageBuilder and $this->queueFactory, the promoted constructor properties of the 3.x AmazonSes plugin. Amazon SES 2.x, which also supports Drupal 10, keeps $this->config instead, so every send fails with "Call to a member function get() on null". The ^3.1 constraint is only in require-dev of composer.json, which is not applied when the project is installed. Use amazon_ses:amazon_ses (>=3.1) in the info file (and mention it in the README).

3. Uninstalling html_mail_ses breaks outgoing mail (modules/html_mail_ses): the README tells site builders to run drush cset system.mail interface.default html_mail_ses (or per module), but the submodule has no .install file. After it is uninstalled, MailManager::getInstance() still asks for the html_mail_ses plugin and throws PluginNotFoundException for every message routed to it, so password reset emails and the like end in an error. A hook_uninstall() that resets those system.mail:interface entries (to amazon_ses_mail, or removes them) would prevent it.

4. html_mail_symfony takes its copy of the message too early (modules/html_mail_symfony/src/SymfonyMailerHtml.php, mailAlter()): the message array is stored by value when this module's hook_mail_alter() runs, and postRender() later replaces both bodies from that copy. Changes made by hook_mail_alter() implementations that run after it (same weight, module name sorting after html_mail_symfony) are silently dropped. The stored entry is also never removed when a later alter cancels the send ($message['send'] = FALSE), so it can be picked up by a later message with the same id in a long-running cron or queue process. Running the alter last (hook_module_implements_alter(), or order: Order::Last on Drupal 11.2+) and clearing the entry in mailerInit() would cover both.

Minor:
- The mail_style Twig filter is declared 'is_safe' => ['html'] without 'pre_escape' => 'html', so a theme override such as {{ notice|mail_style(...) }} prints the string unescaped. SECURITY.md documents the rule, but adding pre_escape enforces it and keeps Markup objects unchanged.
- linkify() runs on already-escaped text, so See <https://example.com/a> produces href="https://example.com/a&amp;gt" followed by a stray ;.
- The preheader is strip_tags() of the escaped body, so apostrophes appear as &#039; in the inbox preview and paragraphs are joined without a space.

Status: Needs work (points 1 to 3).

jamestarleton’s picture

Thanks for the detailed review, @shaker-gspikes. Really appreciate it. All your findings were confirmed and valid, and each has been fixed in 1.0.x as of commit 210a98e.

1. Body lines treated as plain text
Good catch, I didn't account for the token escaping. The body is now handled the same way core's PhpMail::format() does it: the lines are joined and run through MailFormatHelper::htmlToText(), and that is the plain-text part. The HTML part is built from that same text, so the two always match. "O'Brien Dental" now comes out right in both parts, and the contact module's rendered markup gets converted instead of showing raw tags. If a module wants real HTML, it can still use the body hint, which goes through Xss::filter(). Also have added a kernel test with an escaped token value and a Markup body line.

2. html_mail_ses needs Amazon SES 3.1
The info file now says amazon_ses:amazon_ses (>=3.1), and the README mentions it. I also did the same for html_mail_symfony, which now declares symfony_mailer:symfony_mailer (>=1.5) to match what the README already said.

3. Uninstalling html_mail_ses breaks mail
Added html_mail_ses.install with a hook_uninstall(). It points any system.mail:interface entries that use html_mail_ses back to amazon_ses_mail, so mail still goes out through SES. It skips this during a config sync, since the imported system.mail wins there anyway. Also have added a kernel test.

4. html_mail_symfony copies the message too early
Thanks for the suggestion to move the hook to the end. I ended up with a slightly different fix, since Order::Last isn't available on D11.1, which the module still supports:
Saved $message by reference - hook_mail_alter() now stores a reference to capture changes from all subsequent alter hooks.
Moved the template check to post-render - the check for whether a message gets the template now runs in the post-render callback, alongside the build, so late changes (like html_mail hints) are included regardless of hook execution order.
Fixed leftover data from cancelled emails leaking into next send - mailerInit() runs at the start of every send, before the alter hooks. It now also deletes any message still stored for that ID, so every send starts clean.

Minor points

  • mail_style now has 'pre_escape' => 'html'. Plain strings get escaped first, and Markup is left unescaped and just gets the inline styles. There's a kernel test for both cases.
  • linkify() now finds URLs in the raw text and escapes afterwards, so <https://example.com/a> produces https://example.com/a as expected instead of https://example.com/a&amp;gt and without the stray ;.
  • The preheader decodes entities (Twig escapes it once on output) and puts a space between paragraphs.

PHPCS (Drupal, DrupalPractice) and PHPStan level 8 are clean, and all tests pass locally and in CI on both Drupal 10 and 11.

Setting back to Needs review.

jamestarleton’s picture

Status: Needs work » Needs review