New
- Email actions and plugins can now set custom headers on a message with a
headersparameter, a map of header name to value, applied after everything else. The pair this was added for isList-UnsubscribeandList-Unsubscribe-Post, which together are RFC 8058 one-click unsubscribe: the unsubscribe button next to the sender name in Gmail and Outlook, and the thing a bulk sender is now expected to have. Setting a header that is already there replaces it, a value may be a list for a header that is allowed to repeat, and a name or value that cannot be written is skipped and logged rather than failing the whole send. Plugins building their own message can pass the same list toapplyHeaders(), and can askEmail::supportsParameter('headers')first instead of comparing version numbers - A test suite, run with
tests/vendor/bin/phpunitaftercomposer installinsidetests/. It installs intotests/vendorfrom its owntests/composer.json, so thevendordirectory the plugin ships stays free of development packages - A provider contract under
classes/Providers/, so that everything a mail provider knows about itself — how its delivery webhooks are verified and read, how a webhook is created from a pasted API key, what a sending domain's DNS has to say, and what its transport does to custom headers on the way out — lives in that provider's owngrav-plugin-email-<provider>plugin rather than in whatever add-on happened to need the answer first. A transport plugin implementsProviders\Providerand registers itself on the newonEmailProvidersevent;Email::providers()answers the registry,Email::providerFor($engine)andEmail::providerByKey($key)find one, and a caller asksEmail::supportsFeature('providers')first rather than comparing version numbers. A transport with no delivery API registers nothing, which is a complete answer: a store can then say plainly that the transport cannot report deliveries instead of showing a webhook address nothing will ever post to. Written up for plugin authors indocs/providers.md Email::buildMailerFor($engine)builds a Symfony mailer for a named engine rather than only for the one the site is configured with, through the same transport builder, the sameonEmailTransportDsnevent and the same DSNs as before. Nothing sends through it yet; it is there so that a site which one day wants some of its mail to leave through a second provider has somewhere to ask for that mailer, without that day being the day the transport builder gets rewrittenProviders\Event::$hardnow has a documented second meaning on adroppedevent, which is the difference between a subscriber who is gone and one who happened to be on the list the morning the store ran out of quota.hard = truemeans the provider refused the address — it is on that provider's suppression list, or bounced, complained or unsubscribed there — and a store may treat it as permanent.hard = falseor null means the provider refused this one message: a daily quota, a virus scan, content it did not like. The address is fine.Event::isRefusedAddress()is the question a suppression list should be asking, anddocs/providers.mdsets out which of each provider's refusals is whichProviders\SendHeadernames the header a store stamps its send id into, so that every provider answers the same one and nothing has to hard-code a name. It isX-Grav-Send-Idunless the site setsproviders.send_headerin the Email plugin's configuration, or an add-on callsSendHeader::override()to name it for the request. The reading of it lives there too — out of a map of custom args or user variables, a{name, value}header list, or SES's message tags — because seven transport plugins were each about to keep their own copy of the same twenty lines.SendHeader::metadataHeader()is the Postmark twin, which is the same value under theX-PM-Metadata-prefix that is the only way metadata reaches a Postmark webhook