EmailBuilder development: convert existing

Last updated on
7 April 2024

The EmailBuilder plug-in is responsible for building an email of a specific type/sub-type (resembling the old hook_mail() callback). Eventually more-and-more modules will use this module natively, writing directly to the new interfaces. This page covers two cases.

  1. Converting an existing module (that you maintain) to use Drupal Symfony Mailer directly.
  2. Adding Drupal Symfony Mailer support to another module (that you don't maintain) by overriding its email building. For example this module includes overrides for Drupal Core, simplenews and commerce.

Create a skeleton

The first step is to create a skeleton builder by copying existing ones and with reference to EmailBuilderInterface. In our first example, we will create the builder for the user module, copying UpdateEmailBuilder. So we will

  1. Search and replace 'update' for 'user'
  2. Delete the contents of the data in the @EmailBuilder annotation.
  3. Delete the implementation of all the functions
  4. Copy the createParams() function from EmailBuilderInterface (it's not used by UpdateEmailBuilder).
<?php

namespace Drupal\symfony_mailer_bc\Plugin\EmailBuilder;

use Drupal\symfony_mailer\EmailFactoryInterface;
use Drupal\symfony_mailer\EmailInterface;
use Drupal\symfony_mailer\Processor\EmailBuilderBase;

/**
 * Defines the Email Builder plug-in for user module.
 *
 * @EmailBuilder(
 *   id = "user",
 *   sub_types = { },
 *   common_adjusters = {},
 *   import = @Translation(""),
 * )
 */
class UserEmailBuilder extends EmailBuilderBase {

  /**
   * Saves the parameters for a newly created email.
   *
   * @param \Drupal\symfony_mailer\EmailInterface $email
   *   The email to modify.
   */
  public function createParams(EmailInterface $email) {
  }

  /**
   * {@inheritdoc}
   */
  public function fromArray(EmailFactoryInterface $factory, array $message) {
  }

  /**
   * {@inheritdoc}
   */
  public function build(EmailInterface $email) {
  }

  /**
   * {@inheritdoc}
   */
  public function import() {
  }

}

Decide the create parameters

The create parameters should contain the minimum information to allow building an email. For the user module, the sub-type is used to identify which email to send – this is a given and we don't need code for it. The only other information we need is the user, so add this to the function arguments. The function implementation needs to save each parameter, normally using setParam() or possibly setVariable() to expose the data as a Twig variable. So the function becomes like this:

  /**
   * Saves the parameters for a newly created email.
   *
   * @param \Drupal\symfony_mailer\EmailInterface $email
   *   The email to modify.
   * @param \Drupal\user\UserInterface $user
   *   The user.
   */
  public function createParams(EmailInterface $email, UserInterface $user = NULL) {
    assert($user != NULL);
    $email->setParam('user', $user);
  }

We need to set a default of NULL for each extra argument to ensure compatibility with the interface definition. However the parameter is not actually allowed to be NULL, so add an assert for that.

Copy from hook_mail()

We create the implementation of the build() function by copying from the existing hook_mail().

  $token_service = \Drupal::token();
  $language_manager = \Drupal::languageManager();
  $langcode = $message['langcode'];
  $variables = ['user' => $params['account']];

  $language = $language_manager->getLanguage($langcode);
  $original_language = $language_manager->getConfigOverrideLanguage();
  $language_manager->setConfigOverrideLanguage($language);
  $mail_config = \Drupal::config('user.mail');

  $token_options = ['langcode' => $langcode, 'callback' => 'user_mail_tokens', 'clear' => TRUE];
  $message['subject'] .= PlainTextOutput::renderFromHtml($token_service->replace($mail_config->get($key . '.subject'), $variables, $token_options));
  $message['body'][] = $token_service->replace($mail_config->get($key . '.body'), $variables, $token_options);

  $language_manager->setConfigOverrideLanguage($original_language);

We can convert the code according to the rules in the table below.

hook_mail() build()
$key ::getSubType()
$params ::getParam()
Language switching Delete (automatic)
Absolute URL conversion Delete (automatic)
Filling subject/body/addresses from config Delete (done by Mailer Policy)
::setVariable() to create TWIG variable
$message['subject'] ::setSubject()
$message['body'] ::setBody()
$message['headers']['From']

::setFrom()
::addTextHeader()
NB use arrays and classes - not a comma-separated string or <>

Token replacement Use TokenProcessorTrait
Render an entity ::appendBodyEntity()

You can find all functions available in /modules/contrib/symfony_mailer/src/BaseEmailTrait.php 

Ending up with the following code:

class UserEmailBuilder extends EmailProcessorBase {

  use TokenProcessorTrait;

  /**
   * {@inheritdoc}
   */
  public function preRender(EmailInterface $email) {
    $this->tokenOptions(['callback' => 'user_mail_tokens', 'clear' => TRUE]);
  }

}

Where did all the code go? Well the language switching is automatic and so is filling subject and body from config. The token replacement is in TokenProcessorTrait.

Copy from the mail sending code

Find the code that sends user emails by searching for ->mail('user'. We find it in _user_mail_notify():

  if (\Drupal::config('user.settings')->get('notify.' . $op)) {
    $params['account'] = $account;
    $langcode = $langcode ? $langcode : $account->getPreferredLangcode();
    // Get the custom site notification email to use as the from email address
    // if it has been set.
    $site_mail = \Drupal::config('system.site')->get('mail_notification');
    // If the custom site notification email has not been set, we use the site
    // default for this.
    if (empty($site_mail)) {
      $site_mail = \Drupal::config('system.site')->get('mail');
    }
    if (empty($site_mail)) {
      $site_mail = ini_get('sendmail_from');
    }
    $mail = \Drupal::service('plugin.manager.mail')->mail('user', $op, $account->getEmail(), $langcode, $params, $site_mail);
    if ($op == 'register_pending_approval') {
      // If a user registered requiring admin approval, notify the admin, too.
      // We use the site default language for this.
      \Drupal::service('plugin.manager.mail')->mail('user', 'register_pending_approval_admin', $site_mail, \Drupal::languageManager()->getDefaultLanguage()->getId(), $params);
    }

We add corresponding code to our build() function:

    if ($email->getSubType() != 'register_pending_approval_admin') {
      $email->setTo($email->getParam('user'));
    }

The recipient is the user, except for one sub-type when it is configured (we leave that to Mailer Policy). The language switching is automatic based on the recipient. The configured from address is handled by Mailer Policy, and the default is typically site mail. We already covered the setting of the param in the createParams() function. The check if notifications are enabled would also be handled by Mailer Policy using a "Skip sending" element.

Back-compatibility

This step is required for overriding, otherwise it's not normally necessary. We add support for the old array-based mail interface by coding the fromArray() function. This function takes a message array and creates an Email object. In our case we need to set the single create parameter by referring to the message params.

  public function fromArray(EmailFactoryInterface $factory, array $message) {
    return $factory->newTypedEmail($message['module'], $message['key'], $message['params']['account']);
  }

Configuration import

We can implement the import() function to convert from the legacy configuration to Mailer Policy (see code for full implementation).

Annotation

Sub-types

Create the sub_types array in the annotation by listing all the possible values for the $key parameter of hook_mail(). Often each value will appear as a case in a switch statement, but in this case we need to examine user.schema.yml, which also handily gives a label we can use for each one.

Common adjusters

The common_adjusters annotation element lists some entries to prioritise/highlight to admins using the GUI to configure mailer policy for this plug-in.

Override and import

We need to add some annotation entries to describe overriding and importing, if relevant.

/**
 *   override = TRUE,
 *   import = @Translation("User email settings"),
 *   import_warning = @Translation("This overrides the default HTML messages with imported plain text versions"),
 */

Policy embedding

This part is only for overriding. We would like to change the Account settings form to embed support for editing Mailer Policy and hide the legacy configuration settings that are now ignored. We add code to the annotation comment:

/**
 *   form_alter = {
 *     "user_admin_settings" = {
 *       "remove" = {
 *         "mail_notification_address",
 *         "email_admin_created",
 *         "email_pending_approval",
 *         "email_pending_approval_admin",
 *         "email_no_approval_required",
 *         "email_password_reset",
 *         "email_activated",
 *         "email_blocked",
 *         "email_cancel_confirm",
 *         "email_canceled",
 *       },
 *       "type" = "user",
 *     },
 */

The first part specifies the elements to hide, and the second indicates which Mailer Policy to show.

Config

Drupal core supplies default mail body/subject in user.mail.yml. We make HTML versions of the same, putting them in config entities keyed by the type and sub-type. For example, we create symfony_mailer.mailer_policy.user.status_blocked.yml with the following contents, including by default to skip sending this notification. Instead of editing the YAML file by hand you could use the web UI to create a policy entry, then export it to file. In the case of overriding, the file should be saved to the /config/mailer_override directory instead of the normal place.

langcode: en
status: true
dependencies:
  module:
    - user
id: user.status_blocked
configuration:
  email_subject:
    value: 'Account details for [user:display-name] at [site:name] (blocked)'
  email_body:
    content:
      value: |-
        <p>[user:display-name],</p>
        <p>Your account on <a href="[site:url]">[site:name]</a> has been blocked.</p>
      format: email_html
  email_skip_sending:
    message: 'Notification disabled in settings'

Help improve this page

Page status: No known problems

You can: