- Drupal 9 Module Development
- Daniel Sipos Antonio De Marco
- 3829字
- 2021-06-11 18:36:04
Mail API
Our goal for this section is to see how we can send emails programmatically in Drupal 9. In achieving this goal, we will explore the default mail system that comes with the core installation (which uses PHP mail), and also create our own system that can theoretically use an external API to send mails. We won't go all the way with the latter because it's beyond the scope of this book. We will stop after covering what needs to be done from a Drupal point of view.
In the next and final section, we will look at tokens so that we can make our mailings a bit more dynamic.
The theory behind the Mail API
Like before, let's first cover this API from a theoretical point of view. It's important to understand the architecture before ping into examples.
Sending emails programmatically in Drupal is a two-part job. The first thing we need to do is define something of a template for the email in our module. This is not a template in the traditional sense, but rather a procedural data wrapper for the email you want to send. It's referred to in code as the key or message ID, but I believe that template is a better word to describe it. And you guessed it, it works by implementing a hook.
The second thing that we will need to do is use the Drupal mail manager to send the email using one of the defined templates and specifying the module that defines it. If this sounds confusing, don't worry—it will become clear with the example.
The template is created by implementing hook_mail(). This hook is a special one, as it does not work like most others. It gets called by the mail manager when a client (some code) is trying to send an email for the module that implements it.
The MailManager is actually a plugin manager that is also responsible for sending the emails using a mail system (plugin). The default mail system is PhpMail, which uses PHP's native mail() function to send out emails. If we create our own mail system, that will mean creating a new plugin. Also, the plugin itself is the one actually delivering the emails, the manager simply deferring to it. As you can see, we can't go even a chapter ahead without creating plugins.
Each mail plugin needs to implement MailInterface, which exposes two methods—format() and mail(). The first one does the initial preparation of the mail content (message concatenation and so on), whereas the latter finalizes and does the sending.
However, how does the mail manager know which plugin to use? It checks a configuration object called system.mail, which stores the default plugin (PhpMail) and can also store overrides for each inpidual module and any module and template ID combination. So, we can have multiple mail plugins each used for different things. A quirky thing about this configuration object is that there is no admin form where you can specify which plugin does what. You have to adjust this configuration object programmatically as needed. One way you can manipulate this is via hook_install() and hook_uninstall() hooks. These hooks are used to perform some tasks whenever a module is installed/uninstalled. So, this is where we will change the configuration object to add our own mail plugin a bit later.
However, now that we have looked at a few bits of theory, let's take a look at how we can use the default mail system to send out an email programmatically. You remember our unfinished logger from the previous section? That is where we will send our email whenever the logged message is an error.
Implementing hook_mail()
As I mentioned earlier, the first step for sending mail in Drupal is implementing hook_mail(). In our case, it can look something like this:
/**
* Implements hook_mail().
*/
function hello_world_mail($key, &$message, $params) {
switch ($key) {
case 'hello_world_log':
$message['from'] = \Drupal::config('system.site')- >get('mail');
$message['subject'] = t('There is an error on your website');
$message['body'][] = $params['message'];
break;
}
}
This hook receives three parameters:
- The message key (template) that is used to send the mail
- The message of the email that needs to be filled in
- An array of parameters passed from the client code
As you can see, we are defining a key (or template) named hello_world_log, which has a simple static subject, and as a body, it will have whatever comes from the $parameters array in its message key. Since the From email is always the same, we will use the site-wide email address that can be found in the system.site configuration object. You'll note that we are not in a context where we can inject the configuration factory as we did when we built the form. Instead, we can use the static helper to load it.
Additionally, you'll notice that the body is itself an array. This is because we can build (if we want) multiple items in that array that can later be imploded as paragraphs in the mail plugin's format() method. This is in any case what the default mail plugin does, so here we need to build an array.
Another useful key in the $message array is the header key, which you can use to add some custom headers to the mail. In this case, we don't need to because the default PhpMail plugin adds all the necessary headers. If we write our own mail plugin, we can then add our headers in there as well—and all other keys of the $message array for that matter. This is because the latter is passed around as a reference, so it keeps getting built up in the process from the client call to the hook_mail() implementation to the plugin.
That is about all we need to do with hook_mail(). Let's now see how to use this in order to send out an email.
Sending emails
We wanted to use our MailLogger to send out an email whenever we are logging an error. So let's go back to our class and add this logic.
This is how we can start our log() method:
/**
* {@inheritdoc}
*/
public function log($level, $message, array $context = array()) {
if ($level !== RfcLogLevel::ERROR) {
return;
}
$to = $this->configFactory->get('system.site')->get('mail');
$langcode = $this->configFactory->get('system.site')- >get('langcode');
$variables = $this->parser->parseMessagePlaceholders($messa ge, $context);
$markup = new FormattableMarkup($message, $variables);
\Drupal::service('plugin.manager.mail')->mail('hello_world' , 'hello_world_log', $to, $langcode, ['message' => $markup]);
}
First of all, we said that we only want to send mails for errors, so in the first lines, we check whether the attempted log is of that level and return early otherwise. In other words, we don't do anything if we're not dealing with an error and rely on other registered loggers for those.
Next, we determine who we want the email to be sent to and the langcode to send it in (both are mandatory arguments of the mail manager's mail() method). We opt to use the site-wide email address (just as we did for the From value). We use the same configuration object as we used earlier in the hook_mail() implementation. Don't worry—we will shortly take care of injecting the config factory into the class.
Note
When we talk about langcode, we refer to the machine name of a language object. In this case, that is what is being stored for the site-wide default language. Also, we'll default to that for our emails. In a later chapter, we will cover more aspects regarding internationalization in Drupal.
Then, we prepare the message that is being sent out. For this, we use the FormattableMarkup helper class to which we pass the message string and an array of variable values that can be used to replace the placeholders in our message. We can retrieve these values using the LogMessageParser service the same way as the DbLog logger does. So, with this, we are basically extracting the placeholder variables from the entire context array of the logged message.
Lastly, we use the mail manager plugin to send the email. The first parameter to its mail() method is the module we want to use for the mailing. The second is the key (or template) we want to use for it (which we defined in hook_mail()). The third and fourth are self-explanatory, while the fifth is the $params array we encountered in hook_mail(). If you look back at that, you'll notice that we used the message key as the body. Here, we populate that key with our markup object, which has _toString() method that renders it with all the placeholders replaced.
You may wonder why I did not inject the Drupal mail manager as I did the rest of the dependencies. Unfortunately, the core mail manager uses the logger channel factory itself, which in turn depends on our MailLogger service. So, if we make the mail manager a dependency of the latter, we find ourselves in a circular loop. So, when the container gets rebuilt, a big fat error is thrown. It might still work, but it's not alright. So, I opted to use it statically, because, in any case, this method is very small and would be difficult to test due to its expected result being difficult to assert (it sends an email). Sometimes, you have to make these choices, as the alternative would have been to inject the entire service container just to trick it. However, that is a code smell and would not have helped anyway had I wanted to write a test for this class.
Even if I did not inject the mail manager, I did inject the rest. So, let's take a look at what we need now at the top of the class:
/**
* @var \Drupal\Core\Logger\LogMessageParserInterface
*/
protected $parser;
/**
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* MailLogger constructor.
*
* @param \Drupal\Core\Logger\LogMessageParserInterface $parser
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_ factory
*/
public function __construct(LogMessageParserInterface $parser, ConfigFactoryInterface $config_factory) {
$this->parser = $parser; $this->configFactory = $config_ factory;
}
And finally, all the relevant use statements that we were missing:
use Drupal\Core\Logger\LogMessageParserInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Logger\RfcLogLevel;
Finally, let's quickly also adjust the service definition of our mail logger:
hello_world.logger.mail_logger:
class: Drupal\hello_world\Logger\MailLogger
arguments: ['@logger.log_message_parser', '@config.factory']
tags:
- { name: logger }
We simply have two new arguments—nothing new to you by now.
Clearing the caches and logging an error should send the logged message (with the placeholders replaced) to the site email address (and from the same address) using the PHP native mail() function. Congratulations! You just sent out your first email programmatically in Drupal 9.
Altering someone else's emails
Drupal is powerful not only because it allows us to add our own functionality but also because it allows us to alter existing functionality. An important vector for doing this is the alter hooks system. Remember those from Chapter 2, Creating Your First Module? These are hooks that are used to change the value of an array or object before it is used for whatever purpose it was going to be used for. When it comes to sending emails, we have an alter hook that allows us to change things on the mail definition before it goes out: hook_mail_alter(). For our module, we don't need to implement this hook. However, for the sake of making our lesson complete, let's take a look at how we could use this hook to, for example, change the header of an existing outgoing email:
/**
* Implements hook_mail_alter().
*/
function hello_world_mail_alter(&$message) {
switch ($message['key']) {
case 'hello_world_log':
$message['headers']['Content-Type'] = 'text/html; charset=UTF-8; format=flowed; delsp=yes';
break;
}
}
So, what is going on here? First of all, this hook implementation gets called in each module it is implemented in. It's not like hook_mail() in this respect as it allows us to alter emails sent from any module. However, in our example, we will just alter the mail we defined earlier.
The only parameter (passed by reference as is usual with alter hooks) is the $message array, which contains all the things we built in hook_mail(), as well as the key (template) and other things added by the mail manager itself, such as the headers. So, in our example, we are setting an HTML header so that whatever is getting sent out could be rendered as HTML. After this hook is invoked, the mail system formatter is also called, which, in the case of the PhpMail plugin, transforms all HTML tags into plain text, essentially canceling out our header. However, if we implement our own plugin, we can prevent that and successfully send out HTML emails with proper tags and everything.
So, that is basically all there is to altering existing outgoing mails. Next, we will take a look at how we can create our own mail plugin that uses a custom external mail system. We won't go into detail here, but we will prepare the architecture that will allow us to bring in the API we need and use it easily.
Custom mail plugins
In the previous section, we saw how we can use the Drupal mail API to send emails programmatically. In doing so, we used the default PHP mailer, which although is good enough for our example, might not be good enough for our application. For example, we might want to use an external service via an API.
In this section, we will see how this works. To this end, we will write our own mail plugin that does just that, and then simply tell Drupal to use that system instead of the default one. Yet another plugin-based, non-invasive, extension point.
Before we start, I would like to mention that we won't go into any kind of detail related to the potential external API. Instead, we will stop at the Drupal-specific parts, so the code you will find in the repository won't do much—it will be used as an example only. It's up to you to use this technique if you need to.
The mail plugin
So let's start by creating our Mail plugin class, and if you remember, plugins go inside the Plugin folder of our module namespace. And mail plugins belong inside a Mail folder. So this is what a simple skeleton mail plugin class can look like:
namespace Drupal\hello_world\Plugin\Mail;
use Drupal\Core\Mail\MailFormatHelper;
use Drupal\Core\Mail\MailInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines the Hello World mail backend.
*
* @Mail(
* id = "hello_world_mail",
* label = @Translation("Hello World mailer"),
* description = @Translation("Sends an email using an external API specific to our Hello World module.")
* )
*/
class HelloWorldMail implements MailInterface, ContainerFactoryPluginInterface {
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static();
}
/**
* {@inheritdoc}
*/
public function format(array $message) {
// Join the body array into one string.
$message['body'] = implode("\n\n", $message['body']);
// Convert any HTML to plain-text.
$message['body'] = MailFormatHelper::htmlToText($message['body']);
// Wrap the mail body for sending.
$message['body'] = MailFormatHelper::wrapMail($message['body']);
return $message;
}
/**
* {@inheritdoc}
*/
public function mail(array $message) {
// Use the external API to send the email based on the $message array
// constructed via the `hook_mail()` implementation.
}
}
As you can see, we have a relatively easy plugin annotation; no unusual arguments there. Then, you will note that we implemented the mandatory MailInterface, which comes with the two methods implemented in the class.
I mentioned the format() method earlier and said that it's responsible for doing certain processing before the message is ready to be sent. The previous implementation is a copy from the PhpMail plugin to exemplify just what kind of task would go there. However, you can do whatever you want in here, for example, allow HTML tags. Imploding the body is something you will probably want to do anyway, as it is kind of expected that the mail body is constructed as an array by hook_mail().
The mail() method, on the other hand, is left empty. This is because it's up to you to use the external API to send the email. For this, you can use the $message array we encountered in the hook_mail() implementation.
Lastly, note that ContainerFactoryPluginInterface is another interface that our class implements. If you remember, that is what plugins need to implement in order for them to become container-aware (for the dependencies to be injectable). Since this was only example code, it doesn't have any dependencies, so I did not include a constructor and left the create() method empty. Most likely, you will have to inject something, such as a PHP client library that works with your external API. So, it doesn't hurt to see this again.
That is pretty much it for our plugin class. Now, let's take a look at how we can use it because for the moment, our hello_world_log emails are still being sent with the default PHP mailer.
Using mail plugins
As I mentioned earlier, there is no UI in Drupal to select which plugin the mail manager should use for sending emails programmatically. It figures it out inside the getInstance() method by checking the system.mail configuration object, and more specifically, the interface key inside that (which is an array).
By default, this array contains only one record, that is, 'default' => 'php_mail'. That means that, by default, all emails are sent with the php_mail plugin ID. In order to get our plugin in the mix, we have a few options:
- We can replace this value with our plugin ID, which means that all emails will be sent with our plugin.
- We can add a new record with the key in the module_name_key_name format, which means that all emails sent for a module with a specific key (or template) will use that plugin.
- We can add a new record with the key in the module_name format, which means that all emails sent for a module will use that plugin (regardless of their key).
For our example, we will set all emails sent from the hello_world module to use our new plugin. We can do this using the hook_install() implementation, which runs whenever the module is installed.
Install (and uninstall) hooks need to go inside a .install PHP file in the root of our module. So this next function goes inside a new hello_world.install file. Also, if our module has already been enabled, we will need to first uninstall it and then install it again to get this function to fire:
/**
* Implements hook_install().
*/
function hello_world_install($is_syncing) {
if ($is_syncing) {
return;
}
$config = \Drupal::configFactory()->getEditable('system. mail');
$mail_plugins = $config->get('interface');
if (in_array('hello_world', array_keys($mail_plugins))) {
return;
}
$mail_plugins['hello_world'] = 'hello_world_mail';
$config->set('interface', $mail_plugins)->save();
}
The first thing we do is check whether the module is installed as part of a configuration sync, and if it is, we do nothing. There are two main reasons for this. First, when modules are installed as part of a configuration sync (such as a deployment to another environment), we cannot rely on what configuration has already been imported in order to change it. Second, the assumption is that when we install this module locally via normal means and then export the site configuration to files, the configuration change we make will be exported as well. So, when we sync this configuration on another environment, our changes will be reflected. We will talk more about configuration later on.
Next, we load the configuration object as editable (so we can change it), and if we don't yet have a record with hello_world in the array of mail plugins, we set it and map our plugin ID to it. Lastly, we save the object.
The opposite of this function is hook_uninstall(), which goes in the same file and—expectedly—gets fired whenever the module is uninstalled. Since we don't want to change a site-wide configuration object and tie it to our module's plugin, we should implement this hook as well. Otherwise, if our module gets uninstalled, the mail system will fail because it will try to use a nonexistent plugin. So, let's tie up our loose ends:
/**
* Implements hook_uninstall().
*/
function hello_world_uninstall($is_syncing) {
if ($is_syncing) {
return;
}
$config = \Drupal::configFactory()->getEditable('system. mail');
$mail_plugins = $config->get('interface');
if (!in_array('hello_world', array_keys($mail_plugins))) {
return;
}
unset($mail_plugins['hello_world']);
$config->set('interface', $mail_plugins)->save();
}
As you can see, what we did here is basically the opposite. If the record we set previously exists, we unset it and save the configuration object. And the same logic about the configuration sync applies equally.
So now, any mails sent programmatically for the hello_world module will use this plugin. Easy, right? However, since the plugin we wrote is not ready, the code you find in the repository will have the relevant line from the hook_install() implementation commented out so that we don't actually use it.
Mail API recap
In this section we talked about the Mail API by covering some theoretical aspects first, as we are already starting to get used to. We saw what modules need to have in order to send out emails and how we can alter emails being sent by other modules we don't control. Finally, we saw how extendable the mail system is using plugins and how we can write our own to control exactly how and what mechanism we use for sending out emails.
Let's now switch gears and talk about tokens and why they are important for us as module developers.