Problem/Motivation

Allow creating a Symfony Mailer EmailBuilder plugin / policy that is not hardwired to a module or config entity?

Proposed resolution

I propose a fix as follows:

  1. Add a label to the EmailBuilder annotation, which can be omitted if the plugin ID is an entity type or module name
  2. Add a boolean setting proxy to the EmailBuilder annotation, which should be set for any builder proxied on behalf of another module
  3. Fix EmailBuilderManager::processDefinition(). The code to set $definition['provider'] is only needed for proxy definitions.
  4. Rename EmailFactoryInterface::sendModuleEmail(), to sendTypedEmail(string $type).

Original issue:

I have a Drupal project with various custom emails, using the hook_mail() logic from Drupal core.
I have enabled symfony_mailer_bc.module and they seem to work.

However, I am trying to change my custom code to use a native Symfony Mailer implementation, so I can disable symfony_mailer_bc.module in the long run.

But I'm struggling to find good examples / documentation.

I my module I have 2 separate email flows (with different params), so I am creating 2 EmailBuilder plugins:


namespace Drupal\ipv_waiting_list\Plugin\EmailBuilder;

use Drupal\commerce_product\Entity\ProductVariationInterface;
use Drupal\symfony_mailer\EmailInterface;
use Drupal\symfony_mailer\MailerHelperTrait;
use Drupal\symfony_mailer\Processor\EmailBuilderBase;
use Drupal\user\UserInterface;

/**
 * Defines the Email Builder plug-in for ipv_waiting_list module.
 *
 * @EmailBuilder(
 *   id = "ipv_waiting_list",
 *   sub_types = {
 *     "lesson_max_capacity" = @Translation("Email informing lesson reached maximum capacity - notification to coordinators and secretariat"),
 *     "participant" = @Translation("Email to participant on waiting list"),
 *   },
 *   common_adjusters = {"email_subject", "email_body", "email_skip_sending"},
 * )
 */
class WaitingListEmailBuilder extends EmailBuilderBase {

  use MailerHelperTrait;

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

  /**
   * {@inheritdoc}
   */
  public function build(EmailInterface $email) {
    $email->setTo($email->getParam('user'));
  }

}

and


namespace Drupal\ipv_waiting_list\Plugin\EmailBuilder;

use Drupal\commerce_product\Entity\ProductVariationInterface;
use Drupal\symfony_mailer\EmailInterface;
use Drupal\symfony_mailer\MailerHelperTrait;
use Drupal\symfony_mailer\Processor\EmailBuilderBase;

/**
 * Defines the Email Builder plug-in for waiting list confirmation message.
 *
 * @EmailBuilder(
 *   id = "ipv_waiting_list_confirmation",
 *   sub_types = {
 *     "confirmation" = @Translation("Email confirming registration on waiting list"),
 *   },
 *   common_adjusters = {"email_subject", "email_body", "email_skip_sending"},
 * )
 */
class WaitingListConfirmationEmailBuilder extends EmailBuilderBase {

  use MailerHelperTrait;

  /**
   * Saves the parameters for a newly created email.
   *
   * @param \Drupal\symfony_mailer\EmailInterface $email
   *   The email to modify.
   * @param string|null $recipient
   *   The (optional) mail receiving user.
   * @param \Drupal\commerce_product\Entity\ProductVariationInterface|null $lesson
   *   The (optional) lesson.
   */
  public function createParams(EmailInterface $email, string $recipient = NULL, ProductVariationInterface $lesson = NULL) {
    assert($recipient != NULL);
    $email->setParam('recipient', $recipient);
    $email->setParam('lesson', $lesson);
  }

  /**
   * {@inheritdoc}
   */
  public function build(EmailInterface $email) {
    $email->setTo($email->getParam('recipient'));
  }

}

Now when I go to /admin/config/system/mailer/policy/add, I expect to see both in the Type dropdown, but I only see the module name. When I select that, I see the 2 subtypes of my WaitingListEmailBuilder class. No sign of the WaitingListConfirmationEmailBuilder type / subtype.

Is there only 1 EmailBuilder per module possible? Doesn't seem so, because symfony_mailer_bc contains multiple... Not sure why I'm not seeing them both though...

When I then select a subtype of WaitingListEmailBuilder and try to add a Body element, I get PHP notices and cannot proceed:

Notice: Undefined index: content in /app/web/modules/contrib/symfony_mailer/src/Plugin/EmailAdjuster/BodyEmailAdjuster.php on line 56

Notice: Trying to access array offset on value of type null in /app/web/modules/contrib/symfony_mailer/src/Plugin/EmailAdjuster/BodyEmailAdjuster.php on line 61

Also, I'm not sure what to replace the code with, that used to call the Drupal core mail.manager.
I now have:

        /** @var \Drupal\symfony_mailer\EmailFactoryInterface $email_factory */
        $email_factory = \Drupal::service('email_factory');
        $email_factory->newModuleEmail('ipv_waiting_list_confirmation', 'confirmation')
          ->setParam('recipient', $entity->getEmail())
          ->setParam('commerce_product_variation', $this->lesson)
          ->send();

However I'm not sure if the module name or the type is needed for the first parameter. Seems the type should work, but why does this imply it's for a specific module? Maybe there should be a general $email_factory->newEmail()? Although that's probably nit-picking if I know I can use newModuleEmail().

It would seem to me this is a pretty basic use case? What am I doing wrong exactly? Am I missing some point?

I have checked the doc page https://www.drupal.org/docs/contributed-modules/symfony-mailer-0/develop... but that seems to be aimed towards letting my module work with symfony_mailer_bc, whereas I want it to work natively...

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

svendecabooter created an issue. See original summary.

svendecabooter’s picture

After some extra debugging, I found out that the plugin IDs should either be the same as a module name, or as a entity type.
That is not very clear from the documentation.

The second EmailBuilder I wanted to add is linked with a specific content entity, so I thought I'd create a plugin with the entity type as ID, and has_entity annotation set to TRUE.
However, the EmailFactory::newEntityEmail() only applies to config entities, not content entities.
So I guess I misinterpreted the purpose of that functionality again.

So I will change the purpose of this issue to the following:
Are there any plans to allow creating a simple Symfony Mailer EmailBuilder plugin / policy that is not hardwired to a module or config entity? Would that be complex to implement?

svendecabooter’s picture

Title: How to create native EmailBuilder in custom module? » Allow EmailBuilder plugin not linked to module name or config entity?
Category: Support request » Feature request
svendecabooter’s picture

Issue summary: View changes
svendecabooter’s picture

Some extra context about why I don't think all of my logic fits nicely in the module-specific Email Builder:

The module provides a simple form where a (anonymous) visitor enters their email address, to be added to the waiting list for a lesson (= product variation entity). They get a confirmation message about that, and a "waiting_list_entry" content entity gets created.
So here my EmailBuilder only receives the lesson parameter.

Afterwards an admin user can send an email message to a waiting list entry entity (like "Hey, a new spot is available").
This EmailBuilder needs to receive the waiting_list_entry entity parameter.

I guess I can create one plugin that receives one or the other entity, and switches logic for both use cases. But it feels like dirty code to me. Guess it'll be my only viable approach for now though. However I would have preferred a separate EmailBuilder and separate policy configuration for each type of mail.

adamps’s picture

Thanks for a clear explanation.

After some extra debugging, I found out that the plugin IDs should either be the same as a module name, or as a entity type.
That is not very clear from the documentation.

I'd welcome a patch to improve it. It's fairly clear on EmailFactoryInterface, but I agree that's rather late, because you already wrote the EmailBuilder by then.

The second EmailBuilder I wanted to add is linked with a specific content entity, so I thought I'd create a plugin with the entity type as ID, and has_entity annotation set to TRUE.

I have considered to allow the entity to be a content one. There might be some difficulties though. There can be 1000s of instances of a content entity which would lead to a corresponding huge number of different Mailer Policies. Content entities aren't required to have a string ID it can also be an int.

have checked the doc page https://www.drupal.org/docs/contributed-modules/symfony-mailer-0/develop... but that seems to be aimed towards letting my module work with symfony_mailer_bc, whereas I want it to work natively...

I agree, it would be great to have a doc page for creating a native email builder. I would be very happy if someone created it. There are currently two examples that I know of:

However I would have preferred a separate EmailBuilder and separate policy configuration for each type of mail.

Did you try to create an EmailBuilder for a sub-type? If you look at EmailFactory::initEmail() then there is some code in this direction: it searches for a plug-in name based on the "suggestions" - which means first TYPE_SUBTYPE then TYPE. Probably this code has never been tested. I would welcome patches that fix it.

Maybe there should be a general $email_factory->newEmail()? Although that's probably nit-picking if I know I can use newModuleEmail().

The email would still need to have a type and the function needs a name that clarifies it is an alternative to newEntityEmail(). So it could be newTypedEmail() or newStringEmail(). Which leads to your next question....

Are there any plans to allow creating a simple Symfony Mailer EmailBuilder plugin / policy that is not hardwired to a module or config entity?

The old hook_mail system required $module and $key and this module is similar. I see a difficulty with allowing use of any string for the type: how would you ensure the string is unique? True, your strings all starts with your module name, but even so it's not guaranteed unique as for example someone might create a contrib module called mail_send and someone else mail_send_bulk.

znerol’s picture

Did you try to create an EmailBuilder for a sub-type? If you look at EmailFactory::initEmail() then there is some code in this direction: it searches for a plug-in name based on the "suggestions" - which means first TYPE_SUBTYPE then TYPE. Probably this code has never been tested. I would welcome patches that fix it.

Filed #3281730: Individual email builder class for every subtype.

svendecabooter’s picture

@znerol: ah yes thanks for spotting that! That does indeed allow me to define multiple plugins within the same module. Will update your issue with my additional findings.

adamps’s picture

Title: Allow EmailBuilder plugin not linked to module name or config entity? » Allow EmailBuilder plugin not linked to module name or config entity
Version: 1.0.0-alpha9 » 1.x-dev
Component: Miscellaneous » Code

I thought some more and I would like to fix this issue. It's the same for every plug-in - in theory there could be clash between plug-in ID in different modules but in practice it doesn't seem to be a problem.

I propose a fix as follows:

  1. Add a label to the EmailBuilder annotation, which can be omitted if the plugin ID is an entity type or module name
  2. Fix EmailBuilderManager::processDefinition(). The code to set $definition['provider'] is only needed for back-compatibility emails, so should be moved into symfony_mailer_bc_mailer_builder_info_alter, conditional on provider == symfony_mailer_bc.
  3. Rename EmailFactoryInterface::sendModuleEmail(), my best idea is sendTypedEmail(string $type).
znerol’s picture

Fix EmailBuilderManager::processDefinition(). The code to set $definition['provider'] is only needed for back-compatibility emails, so should be moved into symfony_mailer_bc_mailer_builder_info_alter, conditional on provider == symfony_mailer_bc.

This is a very good Idea.

adamps’s picture

Issue summary: View changes
Status: Active » Needs review
StatusFileSize
new14.35 KB

Please can people test and review?

  • AdamPS committed f66c095 on 1.x
    Issue #3281367 by AdamPS, svendecabooter, znerol: Allow EmailBuilder...
adamps’s picture

Status: Needs review » Fixed

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

joelpittet’s picture

It may have helped us a bit track down a bug with our integration if the release notes for alpha11 had the interface changes listed out... maybe

gR3m4ik made their first commit to this issue’s fork.

mrweiner’s picture

Could somebody clarify what the annotations should look like when there are multiple EmailBuilders for a single module, and what the sendTypedEmail() implementation should look like? I'm having trouble piecing it together from this issue and the documentation doesn't cover it as far as I can tell.

EDIT: Nevermind -- there was a silent error in my createParams that was breaking things. For anybody who comes across this in the future, here are my two classes

<?php

namespace Drupal\my_moduel\Plugin\EmailBuilder;

use Drupal\commerce_order\Entity\OrderInterface;
use Drupal\symfony_mailer\Address;
use Drupal\symfony_mailer\EmailInterface;
use Drupal\symfony_mailer\Processor\EmailBuilderBase;

/**
 * Defines the Email Builder plug-in for Store contact emails.
 *
 * @EmailBuilder(
 *   id = "my_moduel_new_order_notification",
 *   label = @Translation("New Order Notification"),
 *   sub_types = {
 *      "default" = @Translation("A new order notification to be sent to the store owner."),
 *   },
 *   common_adjusters = {},
 * )
 */
class NewOrderNotificationEmailBuilder extends EmailBuilderBase {

  /**
   * Saves the parameters for a newly created email.
   */
  public function createParams(EmailInterface $email, OrderInterface $order = NULL, Address $sender = NULL): void {
    assert($order != NULL);
    assert($sender != NULL);

    $email->setParams([
      'order' => $order,
      'sender' => $sender,
    ]);
  }

  /**
   * {@inheritdoc}
   */
  public function build(EmailInterface $email): void {
    /** @var OrderInterface $order */
    $order = $email->getParam('order');
    $store = $order->getStore();
    $recipient = new Address($store->getEmail(), $store->getName());

    $order = $email->getParam('order');
    $email->setTo($recipient)
      ->setFrom($email->getParam('sender'))
      ->setSubject('New order on Handcrafted')
      ->setBody([
        '#markup' => "You have a new order for {$order->getTotalPrice()->__toString()}. View this order from your dashboard.",
      ]);
  }
}
<?php

namespace Drupal\my_module\Plugin\EmailBuilder;

use Drupal\commerce_store\Entity\StoreInterface;
use Drupal\symfony_mailer\Address;
use Drupal\symfony_mailer\EmailInterface;
use Drupal\symfony_mailer\Processor\EmailBuilderBase;

/**
 * Defines the Email Builder plug-in for Store contact emails.
 *
 * @EmailBuilder(
 *   id = "my_module",
 *   label = @Translation("Contact Store"),
 *   sub_types = {
 *      "default" = @Translation("A generic contact email to be sent to a store."),
 *   },
 *   common_adjusters = {},
 * )
 */
class StoreEmailBuilder extends EmailBuilderBase {

  /**
   * Saves the parameters for a newly created email.
   */
  public function createParams(EmailInterface $email, StoreInterface $store = NULL, Address $sender = NULL, string $subject = NULL, string $message = NULL): void {
    assert($store != NULL);
    assert($message != NULL);
    assert($subject != NULL);
    assert($sender != NULL);

    $email->setParams([
      'store' => $store,
      'message' => $message,
      'subject' => $subject,
      'sender' => $sender,
    ]);
  }

  /**
   * {@inheritdoc}
   */
  public function build(EmailInterface $email): void {
    /** @var StoreInterface $store */
    $store = $email->getParam('store');
    $recipient = new Address($store->getEmail(), $store->getName());

    /** @phpstan-ignore-next-line */
    $markup = str_replace("\n", '<br />', $email->getParam('message'));

    $email->setTo($recipient)
      ->setFrom($email->getParam('sender'))
      ->setSubject($email->getParam('subject')) // @phpstan-ignore-line
      ->setBody([
        '#markup' => $markup,
      ]);
  }

}