Problem/Motivation
Part of #1803948: [META] Adopt the symfony mailer component, adds an plugin.manager.email service to the dependency injection container.
The scope of this issue:
There are two activities when it comes to send transactional mail from Drupal:
- Building mails
- Delivering mails
This issue is about the first activity.
The proposed approach:
Add a plugin manager to the experimental mailer module such that bleeding-edge contrib and custom code can start exploring the mail building part. This MR still leaves out essential parts which are going to be tackled in follow-up issues. Proof-of-concept have been implemented in order to asses the viability of the plugin approach.
- Add plugins for all core emails
Branch with a proof-of-concept: 3539651-email-yaml-plugin-zengenuity-with-core-emails - Introduce E-Mail wrapper template and theme switching
Branch with a proof-of-concept: 3539651-email-yaml-plugin-zengenuity-theme-wrapper
Steps to reproduce
Proposed resolution
Email registration
An email plugin is registered to a specific module.key pair in an {module}.emails.yml file. This file lists all emails and specifies how those are built by default:
email_tester.from_template:
label: 'Twig template email'
description: 'Email with twig template'
module: 'email_tester'
key: 'from_template'
html_body_template: 'email_tester_html_body'
text_body_template: 'email_tester_text_body'
email_tester.with_class:
label: 'Email with custom class'
description: 'Email with custom class'
module: 'email_tester'
key: 'with_class'
class: '\Drupal\email_tester\Email\CustomEmail'
For more complex cases, a plugin class can be registered using the #[Email] attribute:
#[Email(
id: 'MYMODULE.complex_email',
label: new TranslatableMarkup('Complex'),
description: new TranslatableMarkup('Complex email'),
)]
d}[startinline]{php}
class ComplexEmail extends EmailPluginDefault implements EmailPluginInterface {
public function htmlBody(): RenderableInterface|array|null {
$result [];
[...]
return $result;
}
}
Email plugins
A default email plugin covers simple cases. You can specify twig templates in your YAML declaration that will be used for the HTML and/or text bodies of the email. These twig templates can use parameters, which are mapped to twig variables for use in the templates.
If that is not enough, a custom plugin class may override whatever is necessary.
Call site
In order to send a registered email, the call site uses the email plugin manager to create an email plugin instance.
$email = $this->emailManager->createInstance('email_tester.from_template');
$email->subject('Test from template')
->sender('from@example.com')
->to('to@example.com')
->langcode('en')
->params([
'name' => 'John Doe',
'bill_amount' => '$123.45',
]);
$result = $this->emailManager->send($email);
Templates and Theming
Email sending core, contrib and custom modules are responsible to declare the templates in a hook_theme needed to construct an email.
Hooks
Third parties may modify any email about to be rendered and sent using the following hooks:
- hook_email_info_alter
- Alter all the email definitions discovered by the email plugin manager.
- hook_email_pre_build_alter
- Alter the email plugin instance before the email message is built.
- hook_email_pre_render_alter
- Alter the email data array generated by the email plugin.
- hook_email_post_render_alter
- Alter the rendered Symfony email object.
Meeting notes
Notes from the meetings where the current approach has been discussed:
- #3561711: [meeting] 2025-12-03 core mailer beta planning
- #3564527: [meeting] 2025-12-17 core mailer dev meeting
- #3567262: [meeting] 2026-01-07 core mailer beta planning
- #3572252: [meeting] 2026-02-04 core mailer beta planning
- #3578069: [meeting] 2026-03-04 core mailer beta planning
- #3582983: [meeting] 2026-04-01 core mailer beta planning
- #3585552: [meeting] 2026-04-15 core mailer dev meeting
- #3589116: [meeting] 2026-05-06 core mailer beta planning
- #3594021: [meeting] 2026-06-03 core mailer beta planning
- #3609103: [meeting] 2026-07-01 core mailer beta planning
- #3615377: [meeting] 2026-08-05 core mailer beta planning
Sandbox
A sandbox project is available which can be used as a starting point to explore the new plugin based approach.
Previous approaches
- 3539651-introduce-email-yaml (and MR 13328): Core infrastructure necessary to implement this approach.
- 3539651-introduce-email-yaml-core-emails: All core emails converted to the new approach.
- 3539651-introduce-email-yaml-all-in-one: All branches merged with the following git command:
git merge \ 3125013-deprecate-updatecronnotify-and \ 3397418-ensure-origin-headers \ 3539178-extract-usermailnotify-into \ 3548968-add-symfonytype-info-component \ 3549756-implement-stringable-for git merge 3539651-email-content-yaml-plugin git merge 3539651-email-content-yaml-plugin-core-emails composer install - #3536575: Introduce EmailTemplate config entity and plugin type
- #3529014: Generate emails from entities using pluggable entity handlers
- Define each email as an SDC (no issue link)
Remaining tasks
User interface changes
Introduced terminology
API changes
Data model changes
Release notes snippet
| Comment | File | Size | Author |
|---|---|---|---|
| #34 | email_tester.zip | 8.53 KB | zengenuity |
Issue fork drupal-3539651
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:
- 3539651-email-yaml-plugin-zengenuity-theme-wrapper
compare
- 3539651-email-yaml-plugin-zengenuity-with-core-emails
compare
- 3539651-email-yaml-plugin-zengenuity
changes, plain diff MR !14281
- 3539651-email-yaml-plugin-zengenuity-with-tests
compare
- 3539651-introduce-email-yaml
changes, plain diff MR !12912
- 3539651-email-content-yaml-plugin
changes, plain diff MR !13328
- 3539651-email-content-yaml-plugin-core-emails
compare
- 3539651-email-content-yaml-plugin-all-in-one
compare
- 3539651-all-in-one-integration
compare
- 3539651-core-emails
compare
Comments
Comment #3
znerol commentedComment #4
adamps commentedI had a first go to understand, and found it not so easy😃. I feel that for a module to send an email should not be so complicated. It seems much simpler in DSM+. Could you help me compare them? Either we could make this simpler, or list clear reasons why it is more complex.
1) These lines seem to happen a lot. I feel it should be just one line to send a mail. The underlying layer can handle the separate build/render/send.
2) I'm confused by inconsistencies between the modules, they seem far from following a pattern. Some have a builder some not. I feel that one class is enough and it can have separate functions for the parts. All modules can have the same 2 functions.
Comment #5
znerol commentedTrue. The call site needs more attention at some point. I'd like to keep the question open for a while to see what kind of abstraction best fits these cases. It very much depends also on whether or not we want to support passing a closure to
$emailManager->getEmailDefinition().I tried to present many different approaches in
3539651-core-emails. E.g., the confusingly namedActionMailBuilderclass is an action plugin and a mail builder at the same time (demoing the closure approach). TheUpdateMailBuildershows that it is still possible to create text-only emails (question is whether we want to support that or not).UserNotificationHandlershows the closure approach with first class callable syntax,ContactMailHandlershows how it could look like if the "controller" is set via yml file.If we want to continue with this approach for a bit longer, we should probably decide which pattern is to become the standard one.
Comment #6
zengenuity commentedI general, I like the idea of declaring emails with a plugin like this. The YAML syntax is very straightforward and consistent with other plugins, and for more dynamic use cases, developers can use a deriver class. Having the emails formally declared opens up use cases that are difficult to deal with now. In Easy Email, I had to make my own plugin for emails and then declare the core emails myself in order to make them overridable. I think discoverability will be nice for other use cases like ECA, as well.
A couple of specific changes I propose we consider for the plugin definition.
1. Option for template instead of controller: I like the _controller option. It makes rendering an email very similar to rendering a route. But I think many emails could probably be rendered using only a template that would use the provided params as variables. In such cases, the controller is a boilerplate creation of a render array specifying the template. I propose that we allow developers to skip that boilerplate by adding a key to the plugin specification to specify a template instead of a controller. So the developer can choose one or the other. And if they choose neither, we can default to a template that is derived from the email's machine name. The implementation for this could be that we always call a controller, but there's a default controller that we write that renders a template from the email specification if no other controller is specified.
2. Typed data for parameters: In Easy Email, my specification includes data types for the parameters. This allows me to match up the parameters with fields in the email entities, such as entity references for users or Commerce orders. I don't think we should require developers to provide the types for their parameters, but it would be nice to support it if we are able to do so.
3. Consider whether we really should support closures: It's a neat idea, but I'm struggling to figure out the use case where it really helps. Yes, it can be nice to have the definition of the rendering function in the same place as the call site, but it seems like if we encourage this type of behavior, we make it harder for people to override the rendering of emails. In the case where we make everyone who declares an email provide a controller or use a default one that renders a template, that specification will be alterable by any developer who wants to provide their own controller instead. How would that work with a closure? The closure is being created at the same time as the call to send the email is being made, so it seems like it you won't be able to alter it ahead of time. You might be able to catch it before it's rendered, but that's an email by email alter rather than just altering the email specification for all emails of this type. So, I think we should think this one through a little more. Personally, I think a cleaner specification with clearer options for overriding is more important, but I'm open to changing my mind if someone can provide some concrete use cases where the closure is superior.
Regarding the rest of the code in the MR and related commits:
I agree with this sentiment. I think sending an email should be roughly as simple as it is now, which only requires calling a single method with module, key, params, etc. The way it's currently implemented here requires the developer to know more about how the email system works than they probably should have to know. I think if we don't support closures, this should be possible.
Next, I think that your code demonstrates the need for the Drupal adaptation layer that has been a controversial topic in our previous discussions. Here are a few key snippets:
These are alter hooks where you're passing around multiple objects. The specification for what is in the params and context changes a bit per call, but it's clear that passing around the params alone or the Symfony email object alone is not enough. If I'm developer, I have to understand which different types of objects I'm going to get in each of these alters, and I have to know what is expected to be in context at each point, as it changes. The Drupal adaptation layer would provide a single object that we could pass around that would encapsulate the context, params, and potentially the final Symfony email. It would simplify the API that developers have to interact with at each point.
Moreover, I think we should consider using the adaptation layer object in place of the initial params array. For example, most email call site code in the current API looks something like this:
The call site code in your commits is a bit different, but much of the internal code is passing around these same variables. With the adaptation layer, I think we could get to something like this:
I think the latter is easier to read and more discoverable, as the context variables will have specific methods that can be autocompleted in IDEs, found by AI code agents, etc. And now we have one object we can pass around internally, though alter hooks or event subscribers, until we finally get to the point of actually sending the email, where we convert it into a Symfony Email. (We potentially could also have a Symfony Email embedded it that is being updated by the parent object all the time so there's no conversion process at the end. I don't have a strong opinion about that.)
Those are my initial thoughts on the MR and other commits. I think it's a good start on layers 5 and 6, as currently defined in the proposed resolution in #3534136: Requirements and strategy for Core Mailer, but I think it would benefit from us discussing layers 3 and 4 in more detail, which is where my comments above are focused. But overall, I'm feeling optimistic. After seeing this work and talking to @adamps last week, I feel like I'm starting to see the outlines of a workable solution. I'm looking forward to discussing this in more detail at our next meeting.
Comment #7
znerol commentedThanks for the review.
Since this has come up multiple times now, I guess I'm going to iterate on the call site now. There are a couple of well known design patterns which might help in this situation. In my eyes, the current
MailManagerInterfaceis a facade. It provides a convenience method (mail()) which abstracts away the whole complexity of mail plugins, formatting and logging.Looking through core, I can find another well known design pattern which simplify access to complex subsystems. E.g.,
EntityStorageInterface::getQuery()returns an instance of a class implementing the builder pattern. The call site of entity queries resembles the example snippets by @zengenuity and @adamps. I think the builder pattern could be useful. E.g., a call site in the contact mail handler could look like this:In cases where a message needs to be sent to multiple recipients with different languages, the builder can be reused (update module):
In a second step, addressing, sending and exception logging could be moved into a convenience method. It doesn't need to cover all cases (
build()is still there) but it could simplify the most used ones:Contact example:
Update example:
Noted the other comments in #6. All of them are useful as well, thanks!
Comment #8
zengenuity commentedLooking at this code example:
This code suggests that mail ID, parameters, and langcode will not be available at the point the email is being sent. This will complicate the process of anyone attempting to alter the sending of the email, such as rerouting or logging it.
Comment #9
znerol commentedGood point. I think those should be added as tags / metadata to the Symfony Email object. This information is then also available on external mail service providers for filtering and routing.
Comment #10
znerol commentedPushed the
EmailBuilderapproach.Comment #11
znerol commentedRe #8 / #9, the
3539651-core-emailsbranch now contains aSystemEmailHooksimplementation (an example on how this could work):https://git.drupalcode.org/issue/drupal-3539651/-/compare/11.x...3539651...
Comment #12
znerol commentedI pushed a little refactoring of the renderer class. There is now an
EmailControllerParamsobject passed through the whole process. This gets rid of the repeated ad-hoc$contextdefinitions inside therender()function.It also separates the
langcodeinto an (alterable) environment parameters array. Ahook_email_paramsimplementation now can actually modify the language of the mail. The language switching itself is not yet implemented though.Comment #13
znerol commentedI'm trying to reduce the scope of the Draft PR in order to make it smaller and easier to review. As a first step, I'll be extracting features which aren't absolutely necessary to land in the first iteration. I'm thinking of the following things:
Emailclass (allows us to get rid of the custom plugin factory).Anyone has additional suggestions?
Comment #14
znerol commentedComment #15
znerol commentedComment #16
adamps commentedThe routing.yml file is flexible and it can map to many "things" - not only to a controller but also a form, an entity list, and entity form, an entity view, and maybe more. In the case of emails, I feel that we only have "thing" and so we don't need the flexibility. There is another pattern which is more common/familiar and simpler: plug-in plus attribute. We could make an
@EmailBuilderplug-in and put the metadata (currently in the yaml file) on the attribute.The current controller design only covers the email body. To build an email we also need to cover the subject, recipient and perhaps more. These must also be set within the switched environment because they are language dependent, and can affect render context. I feel we should let go of the idea of returning a render array (even though it does give a pleasing similarity with the routing file) and instead the controller should act on the Drupal
EmailInterfaceobject, typically in the build phase, but could also be init or post-render. This gives the entire implementation in a single class which is easily swappable.Comment #17
znerol commented#16 touches on things which seem to be more the topic of #3534136-51: Requirements and strategy for Core Mailer (answer is over there).
Comment #18
berdirI didn't read everything, neither comments nor code, but I do have a few thoughts:
* Route controllers are not plugins and I honestly think that the current way routes are defined is really not great DX. Between defaults, options and other keys they can have, I still have to look up examples every time I do something non-trivial (types for example). There is a reason we tried to move to attributes for route definitions instead of a yaml file for years. I think we shouldn't try to reuse terminology like defaults and controller for this. IMHO, it makes it harder to use, not easier.
* This uses a plugin manager but they aren't really plugins, this is quite confusing. I think plugins are pretty much expected to have a class property in the definition. There can be a default for it when for example using yaml discovery, such as local tasks (see LocalTaskManager) but it's still there. Plugins are then expected to be a class implementing an interface, not just a callback.
* I'm not sure if plugins or something else is better, but I think we should pick a side, either they are plugins or not. If they're not, we shouldn't use a PluginManager.
* On one side, plugins seem like the default choice but if all we need is a callback and especially when that is hard to put on an interface due to dynamic arguments (although I think that's still up for discussion) then that's possibly indeed not the best fit. Plugins are good when you need to instantiate them, have multiple methods and state/configuration. I don't really see that here. For discovery, OOP hooks *might* be an inspiration, not sure (discovery of attributes on methods/classes in a specific namespace, service-autoregistration).
* I do think that figuring out the API at the call site is more important than discovery/definition of the thing that builds the mail and I think it should influence how those things work, not the other way round. Also, to add what what I said in the call we had, I think we didn't fully understand each other yet. In my opinion, what I have in mind might make it *easier* for something like easy mail, not harder. More on that later. The call site API is important and should be generic, but that doesn't mean that it should require as little code as possible to invoke.
Comment #19
znerol commentedThis is good to know. I cannot really tell.
True. In a previous version, the
classwas set to theSymfony\Component\Mime\Email. We do this for constraints, but I guess that this architecture isn't something we want to push more. It also required a custom factory - and I removed it again. The Symfony Email is a value object, it doesn't really map well to a plugin instance.I think there is a way how plugins could have an interface for callers and still support dynamic arguments for the implementation. The methods which currently make out the controllers could be folded into plugin implementations. The argument resolver could be moved from the renderer to a shared plugin base. I guess I need to try that out next.
Comment #23
znerol commentedPivoting to an approach without controller.
Comment #25
znerol commentedComment #26
needs-review-queue-bot commentedThe Needs Review Queue Bot tested this issue. It fails the Drupal core commit checks. Therefore, this issue status is now "Needs work".
This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.
Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.
Comment #27
znerol commentedComment #28
znerol commentedComment #29
zengenuity commented@znerol, I've read through the latest MR, and it's pretty complex. I'm having some trouble following it from reading the code alone. I think we have a meeting scheduled later this week, so perhaps you could give us a walkthrough then of the classes and how you've broken up the responsibilities between the EmailBuilder, EmailRenderer, EmailManager, Email plugin instance class, and modulename.emails.yml definitions. I can understand the code class by class, line by line, but what I feel like I'm missing is the high-level overview of how things fit together and why.
Also, I think it would be helpful for review to have a minimal example module that sends a "Hello {{ user }}" HTML email. I see you have some examples in your test module, but I think those implementations could be more complex than the typical email because you're checking edge cases. An example of what we expect most developers to do would be useful for reviewing the DX of the new API.
Looking forward to discussing this further at our next meeting.
Comment #30
znerol commentedThanks for taking a look @zengenuity.
There are some docs in the MR (mailer.api.php).
The 3539651-email-content-yaml-plugin-core-emails branch shows how the core emails call sites and implementations might look like with this approach applied.
But neither of those really explain the design choices very well.
Comment #31
znerol commentedComment #32
zengenuity commentedI'm attempting to test this, but I can't seem to get even a minimal example working. Which branch should I be using? I'm currently trying with 3539651-email-content-yaml-plugin.
Here's what I've got. In a module called test_rig:
test_rig.email.yml
Then, I've got a controller I can call to trigger an email as a test.
When I run this, I get:
Symfony\Component\Mime\Exception\LogicException: An email must have a "From" or a "Sender" header. in Symfony\Component\Mime\Message->ensureValidity() (line 132 of /userdata/dev/sites/core-dev/vendor/symfony/mime/Message.php).
So, it's not picking up the site mail as the default sender. But then, I also tried to add it to the $email object, and it's not clear how to do that. I don't see any methods on that object that would allow me to add headers. The only examples in the test code are for adding headers to the Symfony Email object in an alter.
Comment #33
znerol commentedThanks @zengenuity, whats missing is #3397418: Ensure origin headers of mails sent using the symfony-based mailer service comply to RFC5322.
Comment #34
zengenuity commentedI have created a simple YAML-based implementation in branch 3539651-email-yaml-plugin-zengenuity.
This implementation may not include all the functionality of @znerol's version, but it's very simple and easy to understand. I'm posting it so that we can discuss the code differences and requirements at a meeting in the near future.
The implementation requires that emails be declared in a YAML file in your module. Beyond that, you have three ways you can implement the content of the emails.
getHeaders(),htmlBody(), andtextBody()methods to provide programmatically generated values.You can also combine these approaches. For example, you could have a custom class and also use declared twig templates for the body of the email.
The system defines four alter hooks:
hook_email_info_alter(&$email_info)hook_email_pre_build_alter($email)hook_email_pre_render_alter(&$body, $context)hook_email_pre_send_alter($email, $symfony_email)Note that
$emailin the hooks is an email plugin object. You can access the Symfony Email object in the last hook. It's also possible to call theEmailManager::build()andEmailManager::send()steps separately if you want to manipulate the Symfony Email object before sending. This is not normally needed, though, and you can callsend(), which will callbuild()internally.I'm attaching a module to this comment that demonstrates the various approaches for sending emails.
Comment #38
znerol commentedComment #39
znerol commentedUpdated the issue summary to reflect the current approach. Also see notes in #3564527: [meeting] 2025-12-17 core mailer dev meeting.
Comment #40
zengenuity commentedI have updated the 3539651-email-yaml-plugin-zengenuity branch with the changes discussed at our last meeting. These changes are:
In the 3539651-email-yaml-plugin-zengenuity-with-core-emails branch, I also took a first pass at converting a core email to use the new system. Right now, only the update notification email is converted. I chose that one because the text is very dynamic, and I wanted something that needed to use a plugin class. It's implemented using just an attribute-defined plugin class, into which I ported the existing code for generating that email body. No YAML file needed, and minimal changes at the call site. The call site is pretty complex, so perhaps more refactoring would be useful, but I skipped that as out of scope for now.
Comment #41
zengenuity commentedBased on our discussion from the last call, I've updated the 3539651-email-yaml-plugin-zengenuity and 3539651-email-yaml-plugin-zengenuity-with-core-emails branches to allow headers to be altered in hook_email_pre_render(). Making this change also allowed me to provide a way to allow altering of the Symfony Email class, which was something @znerol's branch originally had marked as a to-do.
Comment #42
berdirCould you open a MR(s) on the active branch(es) so it's easier to review and discuss the code?
Comment #45
zengenuity commentedI've updated my branches to make some changes that @znerol and I discussed on Slack. These changes are:
Added the ability to set the subject and recipients of the email from the plugin class. There are two new methods for this:
The return value from getDefaultRecipients() is expected to be a nested array like this:
You're not required to have all three recipient type keys in there, but if you have them, and if the corresponding recipient type has not been set at the call site, these will apply.
It's similar with getDefaultSubject(). If no subject is set at the call site, then the value from getDefaultSubject() will apply.
Once I had these in place, I realized we could also allow people to set the subject and recipients statically in YAML declarations, so I implemented that. The structure of the declaration is the same as expected from the methods above.
The defaults are applied in EmailManager before the headers are evaluated using a preBuild() method in the default email plugin class.
With these changes, it's now possible to send the simplest of emails with just a YAML declaration and template file. The call site in this case can be as simple as:
In this case, I would expect subject and recipients to be declared in the YAML file, but you still have the option to set the subject, recipients, and parameters at the call site as needed.
Emails declared by email plugin classes can override:
And then the call site could be as simple as above, with the option to set subject, recipients, and parameters at the call site if needed.
Comment #46
znerol commentedThank you!
UpdateNotification::getDefaultSubject()looks much better.In a first round, I tried to asses whether
default_subjectis feasible for multilingual sites. I think this could be working if the property is declared as translatable in the plugin definition.I also took a look at the Translation template extractor. That module is used to extract translatable strings from core as well as contrib/custom modules. Something like #3411529: field_type_categories.yml translatable strings missing from potx is probably needed to tell the potx extractor where to find translatable strings in email yaml plugin definitions.
Comment #47
znerol commentedI think the service definition for the email plugin manager got missing. @zengenuity could you add that back?
Also it might be worth to make this class autowirable. In order to do that, you'd need to add
#[Autowire(param: 'container.namespaces')]for the$namespacesconstructor argument. And#[Autowire(service: 'cache.discovery')]to the$cache_backendargument.Comment #48
znerol commentedOne of the primary use cases for
hook_email_pre_build_alteris altering email params. With the current API surface, this isn't possible. Its only possible to set all at once, the plugin is lacking methods to get all params and alsoset/get/removeindividual ones.Comment #49
znerol commentedWhile porting the tests from the previous MR, I found that it is likely necessary to make some additional information available to custom templates. E.g., in the previous MR, there was a test with a custom template (email--mailer--custom-template.html.twig. That one uses the
langcode. With the current MR, access to thelangcodefrom a custom twig template is not possible.Comment #50
zengenuity commented@znerol I worked on the issues we discussed this week. Here's what I've done
1. I added back the plugin manager service definition and made it autowirable.
2. I added getters and setters for params on the email plugin default class and updated the interface.
3. I reworked the template declaration to be more like layout discovery. I'm not sure I've done this 100% properly, but it does seem to work. Now, in a module, you can declare your email body templates in modulename.emails.yml like this:
And you don't need to declare them with
hook_theme(). Instead, you put them in anemailssubfolder of your module, and the plugin manager will look for them there and automatically declare them. The theme declaration requires the email object, and there's an initial preprocess that will pull the params and the langcode out of that for use in the twig template. So, the template you declare in the above can have something like this inside:<p>Hello {{ params.name }},</p>If you don't declare html_body_template or text_body_template in your emails.yml file, the plugin manager will attempt to find a template in your module at
emails/email-body-html-[PLUGIN_ID_WITH_DASH_REPLACEMENT].html.twig. So, it's possible now to simply declare the email in the YAML file with no template declarations, and then createemails/email-body-html-XXX.html.twigwithout anything else.Comment #51
znerol commentedIt is still difficult to work with the templating - and write tests for it. For a start I suggest to drop anything which tries to autodiscover template files. Instead implement this simple mechanism:
The email definition may optionally declare a
html_body_template_keyand/or atext_body_template_key. The values are suitable to be used as keys in the array returned fromhook_theme. Like this hey also can be used unmodified as the#themeinDrupal\Core\Mailer\EmailPluginDefault::htmlBodyandDrupal\Core\Mailer\EmailPluginDefault::textBody.Remove
Drupal\Core\Mailer\EmailManager::determineEmailBodyTemplatesentirely. Removetemplateandpathkeys from the theme definitions returned byDrupal\Core\Mailer\EmailManager::getThemeImplementations.Developers will be responsible to put a twig template file wherever the twig loader can locate it. For the following definition:
The twig loader will expect the template file in
mailer_email_test/templates/**/mailer-email-test-custom-body.html.twig.Comment #52
znerol commentedComment #53
berdirAgreed that we should either work with a base template and suggestions, which don't need to exist but are a bit tedious to provide by modules, or make it optional.
It's also not quite clear to me what exactly the HTML templates especially would contain. On top of my head, I have a few thoughts:
* I think a common use case is to have a common wrapper/HTML for all mails, so maybe that should be there by default and we should have two templates, the content and a wrapper?
* It might all be quite different once HTML mails are a built-in feature, but right now, it's pretty common that you need to alter in templates and alter the content (such as converting it to a format that handles basic formatting within a HTML mail)
* even though core currently doesn't actually support HTML mails, in practice, e-mails are quite often built as HTML (for example contact module) and then the HTML is converted to plain text. The result tends to be rather ugly, but it's still more manageable than the opposite, "upcasting" text to HTML. Right now body and text are completely separate. Are plugins expected to provide both? Do we have a primary format that we convert to the other as a fallback? how would a text template even look? how is it translated?
* That said, defining in detail how HTML mails are actually going to work and how far core goes to support that seems way out of scope for this issue and we should probably handle as little as possible of that in this initial issue?
And yes, we really need some tests/example conversions here, it's still all very abstract and hard to imagine how this works in practice.
Comment #54
zengenuity commentedBased on our last meeting, I made the following changes to the 3539651-email-yaml-plugin-zengenuity and 3539651-email-yaml-plugin-zengenuity-with-core-emails branches.
Removed All Autodiscovery of Templates: Modules using the
html_body_templateandtext_body_templateparameters in the email type declaration must also declare whatever template they are using inhook_theme().Ensured that emails can be declared without a template: I think this may have been possible before, but I have confirmed that if you use a class to declare an email type, you don't need to provide a template. I also confirmed that you can use either an HTML or a text template, and you aren't required to use both. If you provide HTML but not text, a plain text version gets automatically generated by Symfony Mailer, but if you provide just a text template, you get a plain text only email.
Provided a Default email type in Mailer module: With this type, it's now possible to send emails solely from the call site, without declaring an email type. This might also be useful for triggering emails from tools like ECA.
Allowed for the key to be automatically derived from the email plugin ID: We still have an open question as to whether module and key are useful parameters for email types. But in the meantime, it's now possible to leave them out of your declaration, and they will be automatically derived.
With the changes, the minimal declaration for an HTML templated email looks like this:
MYMODULE.emails.yml:
In this case, you'd also need to declare
MYMODULE_html_bodyinhook_theme.All other configuration in the YAML file is optional. Here is an example with all available parameters declared except
class:You can declare an email type class either in YAML or by using an attribute on the class. In YAML, this looks like:
As an attribute it looks like this:
When sending an email declared by any of the above, the call site code will look like this:
You can also set subject, recipients, and langcode at the call site using the
subject(),to(), andlangcode()methods.With the default email you can specify the body as a parameter:
It's also possible to use a
textparameter for a text body in the mailer.default email type.Comment #59
znerol commentedComment #60
mmbkStarting to work on this issue, first problem for me was to test the mailer. While the provided Tester-module was a good start, there were some errors, because the component evolved.
So I created a sandbox project for the tester, hoping it might be easier to follow the changes of the mailer-core
Comment #61
mmbkComment #62
znerol commentedThanks a lot @mmbk for the updates. If you are motivated to continue the work, then I suggest to take a look at the PHPCS failures. After that I'd start to cherry-pick the passing tests from the other branch.
Comment #63
znerol commentedComment #65
zengenuity commentedI added a new branch here with an initial version of a wrapper template and CSS handling. (3539651-email-yaml-plugin-zengenuity-theme-wrapper)
I think we have another issue for that (#3486179: Introduce theme negotiator for mail and use it in mail manager), but the MR on that issue was done before all our current plugin work, so I just forked off this issue's branch to add the theme code. I just wanted to get something out for review before our next meeting on July 1.
This system allows an email to have a theme set as an environment property, using the
setTheme()method on the email object. It also allows for a default email theme to be set in config, though there's no UI for it at the moment. The fallback to those is the default theme for the site.Once the theme is determined, the new rendering class will switch the active theme and render the body HTML. It will also look for an
email-librarieskey in the theme's info.yml file to determine which libraries should be attached to the email. The rendering class resolves the attached assets and prints the CSS in a style tag at the beginning of the message. That behavior could be overridden by the email theme, by overriding themailer-email.html.twigtemplate.I'm sure I've missed some things in this initial implementation. In particular, I'm not sure I've got the sequencing right for contrib modules that might want to alter the behavior of the theme wrapping. But this is an initial implementation for discussion.
Comment #66
znerol commentedI did update the current MR (
3539651-email-yaml-plugin-zengenuity) with an API docs file (seemailer.api.php). Additionally I ported the remaining core emails into3539651-email-yaml-plugin-zengenuity-with-core-emailsbranch.Comment #67
znerol commentedAs discussed in #3609103: [meeting] 2026-07-01 core mailer beta planning, I took a look at the question whether or not the approach proposed in #65 to collect the CSS is sufficient. In order to test these assumptions, I created a sandbox. The idea is to essentially use the following fragment with different
$buildrender arrays and then compare$beforeand$after:I added three controller routes with different test scenarios:
/render-assets-example/simple: The$build['#attached']['library']contains a library. In this case$beforeand$afterarray of libraries are equal./render-assets-example/template: The$buildrender array is pointing to a template which uses theattach_librarytwig function. In this case the$beforearray of libraries is empty, the$afterarray of libraries contains the attached library./render-assets-example/wrapper: The$buildrender array is a container with a sub render array equal to the one in the first case. Same result as in the second case: The library attached in the sub render array is not available in the$beforelist of libraries, it is available in the$afterlist of libraries.Note that AttachedAssets::createFromRenderArray actually tries to guard against calling it on an unrendered render array. It throws a
LogicExceptionwhen it detects that the$render_arrayparameter hasn't been rendered yet. But the way it does this isn't that clever. It simply tests whether$render_array['#attached']is set and if that is the case it assumes that the rendering already happened.Comment #68
zengenuity commentedIn the 3539651-email-yaml-plugin-zengenuity-theme-wrapper branch, I've made some updates suggested at our last meeting. The changes are:
I created a new general service for switching the execution environment for rendering emails. Currently, this service only applies theme switching, but I've created it in a generic way so it could do additional switching like language and user.
I moved the environment/theme switching so it happens in EmailManager immediately after going into the render context, before the
preBuild()method is called. There's a try/finally block there to ensure that the environment gets switched back to its original state, even if something in the rendering crashes.I removed the email-libraries functionality. I tested that you can achieve similar results using
{{ attach_library() }}in the template files, and it worked fine. So, I think it's a sufficient solution for that.Comment #69
znerol commentedComment #70
znerol commentedIssue summary should be up-to-date now. I linked the change record, it needs to be updated with information from the IS.
Setting to NR to get some broader feedback.
Comment #71
znerol commentedComment #72
joachim commentedI don't think plugins are the right technique here.
There's an issue that's been around for years which makes emails as config entities (and a proof-of-concept contrib module too). This allows easy customization of email texts in a consistent UI.
Whereas the hooks that this issue proposes leaves every module that wants to offer customisation of texts implementing its own storage of config and its own UI for editing.
Comment #73
znerol commentedRe #72 I tried that (#3536575: Introduce EmailTemplate config entity and plugin type). The group decided against the config + plugin approach in #3539315: [meeting] 2025-07-29 core mailer beta planning. Due to the risks of config going missing and due to the fact that recipes do not encourage installing default config.
That said, I wouldn't be surprised if its still possible to maintain a config based approach in contrib on top of the core plugin (using, e.g., plugin derivatives).
@joachim if you still like to discuss the approach, then please use #3534136: Requirements and strategy for Core Mailer. That one is our issue for higher level discussions. Separating that from the actual implementation issue helps to maintain the discussion on-topic.
Comment #74
joachim commented> That said, I wouldn't be surprised if its still possible to maintain a config based approach in contrib on top of the core plugin (using, e.g., plugin derivatives).
If it goes on top of core, then it's not going to be a *common* UI, is it?
Comment #75
adamps commentedI've just popped in to take a look after being away from this issue for around 1 year. This seems to be heading in a really good direction now, great. Sorry I don't have time to participate fully in the WG, however I am interested in making some occasional comments related to experience from contrib "Mailer Plus".
1) Currently the yml and plugin contains only the body (HTML/plain) and everything else is specified at the call site. I would like to put in a strong vote for moving more into the plugin.
- Localisation: the call site may have the wrong language/user account, whereas switching occurs before call the plug-in, and these are correct. This matters for subject, and addresses (site name can be part of from address). Even if you don't agree with the other points, this seems key on its own. For the same reason we have a split into a hook_mail callback in the old mailer, which was somewhat clumsy and we can improve.
- Consistency: define subject and addresses in same place as body
- Overriding: using standard mechanisms I would like to override the plugin class, and change anything, not just the body
So we could add subject to the yaml file. The plugin could have functions exactly corresponding to the names of the hooks. In each of these, it can call any function on the email object like a hook would, taking the information from the params. Normally it will be pre_render, however it would be pre_build to set the language.
EDIT (in clarification): in addition to the normal benefits of a plug-in, I believe this one is also responsible for context switching (language, user, theme). Inside the plug-in, the context is correct, but at the call site it is not. During Mailer Plus development we discovered that almost any action can rely on context: not only translation in the obvious way, but also any config setting could be localised. The only safe solution we found was to put almost everything inside the plug-in. The call site just sets the parameters. This then resulted in a neat design because all the code was together in the plug-in. For example, the subject and body are quite closely related so developers might expect them to be in the same place. The plug-in can just have a function
build().2) It's great we seem to have fair agreement on what hooks are needed. For consistency, we could use the same hook names as Mailer Plus
pre_build => init
pre_render => build
post_render => SAME
NEW: post_send
Also, order matters a lot between different hooks. In Mailer Plus, as well as hook functions, we had hook classes. Both plug-ins and hook classes implement EmailProcessorInterface, and hook classes are added with addProcessor(). Hooks can choose a weight and hence act either before or after the plug-in. Another option I guess would be to use events.
EDIT: actually I now see that the hook names part is entirely dependent on point 1. The second part about hooks weights and the convenience of allowing classes still seems like an interesting point. Especially in post-rendering, there are many steps that need to happen in a specific order.
3) Instead of separate module and key, have a "tag" (this terminology matching symfony). Tags are dotted identifiers starting with the module name and can be multi-level such as contact.page.mail, simplenews.subscriber.subscribe. Any part yaml/plugins/hooks can operate at any tag prefix level (*, contact, contact.page, contact.page.mail) avoiding redundant duplication. E.g. the subject is the same for contact.user.*.
EDIT: This idea is suggesting that in almost all cases we want the combination of
module.key. This would be the key for mail config entities. So it could be more convenient to store them in the combined form.4) "Email sending core, contrib and custom modules are responsible to declare the templates in a hook_theme needed to construct an email." Experience in Mailer Plus suggests that we don't need this overhead. Instead there can be a single email template that has hook_theme_suggestions based on the tag.
5) For all the functions that that set an address, we can extend them to accept a user account, which will set both email and display name. When setting the "To" address it will also set language, and perhaps also switch user account. This is a great convenience function that also helps ensure consistency and correct language. We could even generalise this feature something like this. We create EmailableInterace with functions getEmail(), getDisplayName(), getLangcode(), getAccount() and extend AccountInterface to implement it. Simplenews Subscriber can implement it, and also so could Commerce Order.
EDIT: This is a feature request. Probably it could easily be postponed and added later. It covers 3 parts:
6) I propose we allow the yml file or attribute to define the available parameters, giving the name and a human-readable label. This information can be used in a UI to display to the admin what variables are available for setting the body or subject.
EDIT: This is a feature request. Probably it could easily be postponed and added later.
7) It's good to allow specifying the text body specifically. However does a template actually work for this purpose, given that Twig is designed to generate HTML? I would anticipate problems with plain text that resembles HTML. Do we actually intend to define the plain text separately in Core modules? I would expect that normally we could rely on automatic conversion of the HTML body.
EDIT: Likely we need 2 parts to make this work.
&<>Comment #76
znerol commentedLet's focus on one thing at a time.
The current approach allows a plugin implementation to do do whatever it likes from within the preBuild() method. The
preBuild()method is called immediately after the context switch. Currently the context switch is just isolating the render context, but later on this also should be switching themes, current user, language.Do you see any deficits in this MR regarding the
preBuild()functionality?Comment #77
adamps commentedThanks @znerol.
That sounds good. I was basing my comment on the example in the IS, which sets the subject and sender at the call site. However subject is very likely to need translation and sender could be localisable too (for example the site name and even email address could vary by language). If instead these were set inside
preBuild()then this would resolve my concern.Comment #78
znerol commentedThe
subject()method takes aStringable- that is lazily evaluated at the point in time when it is casted to astring. That happens when the subject is set on the Symfony Email object. Note, the result oft()isStringableand thus lazily evaluated.The
FromandSenderfields are populated in the OriginatorSubscriber.php. That one respects theContent-Languageheader.As far as I can tell this is sufficient for core requirements. Of course there might be cases when
Fromneeds to be customized. One such case would be the commerce order receipt email - where theFromaddress can be customized for each store. Those cases can be handledpreBuild().Comment #79
adamps commentedSure it can work like that, provided that Developers of call site code don't set a subject from configuration, also don't carelessly force the
Stringableto a string.In Mailer Plus, we enforced that the subject could only be set after context switching, which avoided those possible bugs. It also meant that the subject and body were defined in the same place. So it seems like some advantages, and I don't know what might be a disadvantage.
Anyway I will leave it with you, as I believe we have communicated our ideas clearly to each other.
Comment #80
znerol commentedI reread the MR with a special focus on string translations and I left two suggestions regarding the plugin defined default subject. @zengenuity do you mind taking a look?
Comment #81
adamps commentedThanks. It could help also to put a
t()in the example code in the IS.