-
- Grav 1.7 sites on PHP 7.3 load again. Email 5.3.4 added a PHP 7.4 arrow function to a class that loads on every request, which stopped the whole site with a syntax error #197
- A mail transport that can't be set up now reports why on PHP 7.3 too, instead of failing with a syntax error
- A new test fails when a file that loads on PHP 7.3 uses newer syntax. Only the provider and inbound classes may, since they are never loaded below PHP 8.1
-
- [security] The security fixes below were found by Anthropic's OSS Scanner
- [security] A form value longer than 64 KiB is now cut to that length before an email body is converted with
process_markdown - [security] The Date header of an inbound email is now ignored when it is longer than 256 bytes, and the message is read as having no date
- [security] Addresses that plugins pass to the email plugin are used as plain text, while the addresses in a form's
emailaction and in the plugin settings can still use Twig such as{{ config.plugins.email.to }}
-
- Newsletters can go through a different provider from the rest of the site. A new Newsletter Mail Engine setting (
mailer.marketing_engine) beside the mail engine: site mail such as order confirmations and password resets keeps using the mail engine, and anything marked as marketing goes through the newsletter engine instead. Left on "Same as the mail engine", which is the default, everything goes one way as before. A sender marks a message with$message->marketing();Email::marketingEngine()answers the engine it will use andEmail::splitEngines()whether the two differ. Built for a site that sends its own mail through Cloudflare Email Service, which allows transactional mail only, and its newsletters through Resend or SES - A provider can say it is for transactional mail only.
Capabilitiestakes an optionaltransactionalOnly, for a provider whose terms allow receipts, password resets and notifications but not newsletters or other bulk mail, as Cloudflare Email Service's do. A newsletter add-on reads it and refuses to send a campaign through that transport. A transport plugin checksproperty_exists()before passing it, so it still loads beside an older Email plugin. Written up indocs/providers.md
- Newsletters can go through a different provider from the rest of the site. A new Newsletter Mail Engine setting (
-
- The debug log no longer records credentials. With debug on, the HTTP transports' whole request, headers included, and the SMTP AUTH exchange went into
logs/email.log, which put every API key, token and SMTP password the site sends with into a log file. TheAuthorizationheader, the header-borne tokens providers use and the SMTP password are now replaced with<redacted>before anything is logged or returned bygetLastSendDebug()
- The debug log no longer records credentials. With debug on, the HTTP transports' whole request, headers included, and the SMTP AUTH exchange went into
-
- A provider can name a test address. A transport plugin's provider class may now have
testRecipient(): ?string, answering an address the provider's own documentation names as accepted, delivered to nobody and reported back as delivered, such as Resend'sdelivered@resend.devor the SES mailbox simulator. A store testing its delivery webhook sends there, so the test reaches no real inbox and still produces the event it waits for. It is deliberately not on theProviderinterface, because a new interface method would be a fatal error in every transport plugin not updated in the same release: consumers askmethod_exists(), a plugin that has it still loads beside an older Email plugin, and a plugin without it simply has none. Written up indocs/providers.md, with the rule that only an address the provider documents counts
- A provider can name a test address. A transport plugin's provider class may now have
-
- Updated the bundled Symfony Mailer and Mime to 5.4.52 and the IDN polyfill to 1.43.0, which include the fixes for their published security advisories. Thanks @bago #196
-
- Inbound mail. A plugin that wants to read mail sent to a site — a helpdesk turning replies into ticket updates, a forum taking posts by email — now has one place to ask.
Providers\Inbound\InboundGateway::receive()takes a receiver key, the request and that receiver's config, and runs the same four steps for every provider in the same order: find the receiver (404), check the size against the receiver's limit and the caller's (413, before any verification work), verify (401), and parse, which never throws. What comes back is anInboundResultholding the verdict, the HTTP status to answer with, and the messages. Each message is anInboundMessagewith every field a consumer needs already normalised: message ids without brackets and lower-cased, the envelope recipient kept apart from the visibleToso asupport+token@address survives, the receiving server's SPF, DKIM and DMARC verdicts read fromAuthentication-Results, and attachments with their inlinecid:ids. AskEmail::supportsFeature('inbound')first; it is true on PHP 8.1 and later, like the rest of the provider contract - A provider plugin receives mail by implementing
InboundCapableon the provider class it already registers. It is a separate interface rather than a new method onProvider, because every transport plugin implementsProvidertoday and a new method there would be a fatal error in each one not updated in the same release. The rules are the delivery-report rules — authenticate before acting, over the raw bytes wherever the scheme signs raw bytes;parse()never throws and does no network I/O — plusfetch()for providers that send only metadata, which a consumer runs later from its own worker. Written up indocs/providers.md, which also refines the delivery-report rule to the same wording, since SNS and Mailgun sign inside the payload rather than over it - Two receivers that need no provider account at all.
cloudflaretakes each message from a free Cloudflare Email Routing Worker, byte for byte, signed end to end with an HMAC over the raw body, a timestamp and a 300-second window.genericis the same scheme for a Postfix pipe, a forwarder or a cron job.docs/inbound-cloudflare.mdhas the Worker source, the dashboard steps (including the Subaddressing setting that plus addresses need), Cloudflare's size and CPU limits, and a shell and a PHP sender, all checked against the real verifier - An IMAP client that does not need ext/imap, which left PHP core in 8.4 and was never on most hosts anyway.
Inbound\Imap\ImapMailboxconnects over implicit TLS or STARTTLS (never falling back to plain text), logs in, finds new mail by UID withUIDVALIDITYtracking, downloads it without marking it read, and marks it\Seenand moves it once the caller has stored it — withUID MOVEwhere the server has it and copy, delete and expunge where it does not. Messages over a size limit are reported without being downloaded, and every failure says whether it was the credentials, the network or the server. For Gmail with an app password and most hosting mailboxes;AUTHENTICATE XOAUTH2has its place reserved for later - A MIME parser with no dependencies. Every maintained MIME library for PHP brings packages Grav core also ships at its own version (
guzzlehttp/psr7,pimple/pimple,psr/container) or a dependency-injection container, and Grav loads every plugin's autoloader into one process.Providers\Inbound\MimeParserreads what received mail needs — encoded-word headers, nested multiparts, quoted-printable and base64, charsets (ISO-8859-1 read as Windows-1252, the way mail clients do), RFC 2231 filenames, inline images, forwarded messages kept whole as attachments, delivery reports — and never throws. Tested against Gmail, Outlook (with awinmail.dat) and Apple Mail replies, Latin-1 and Windows-1252 mail, a bounce, an out-of-office and a forward
- Inbound mail. A plugin that wants to read mail sent to a site — a helpdesk turning replies into ticket updates, a forum taking posts by email — now has one place to ask.
-
- A form that drops every one of its recipients no longer does it in silence. An address the plugin cannot parse is discarded, and when the
toparameter produced nothing at all,buildMessage()skipped the call that sets the To header entirely — so the message left with no recipients, the form told the visitor it had been sent, and not one line was written to any log. Every discarded address is now reported to bothlogs/email.logandlogs/grav.log, naming the parameter it came from, the value that could not be read (truncated, so a mailing list cannot fill the log) and whether anything usable was left. The report also names the cause, because in practice there is only one: aName <address>value that has been through Twig's escape filter, written as|eor applied by autoescape, arrives asJohn Doe <john@example.com>, which is not an email address by any reading. Address parameters want|raw. Addresses that do parse are handled exactly as before, including a partial failure — those go out to whoever was left, and are logged too, since half a send is every bit as quiet as none of one. Reported by @thekenshow (grav#3905) - A form whose email could not be sent stops reading the mail server's refusal out to the visitor. What went on the page was the transport's own words with a stop emoji in front of them, which is written for whoever set up the mail account and not for whoever filled in the contact form — it routinely names the server, names the account, and explains precisely why the credentials were rejected, to an anonymous stranger. The transport's text now goes to the Grav log, where the site owner will actually look for it, and the visitor is shown something a site can configure: an
error_messageon the email action, or the new Form send failure message setting in the plugin's Email Defaults, or failing both a translated "Your message could not be sent right now. Please try again in a few minutes." With Grav's debugger switched on the transport's text is appended to it anyway, on the grounds that anybody looking at the form with the debugger running is the person configuring it. Reported by @pboguslawski (form#567)
- A form that drops every one of its recipients no longer does it in silence. An address the plugin cannot parse is discarded, and when the
-
bin/plugin email test-emailsays why a send failed, and what the provider called it when one worked. It answered "Problem sending email..." and nothing else, which is the least useful thing it could say about the one kind of failure only the provider can explain — an unverified sender, a token for the wrong server, an account still pending approval. It now prints the transport's own words and the HTTP response body beneath them, and on a successful send prints the provider's id for the message- An SMTP send gets an id too, read off the transcript. Symfony's
SmtpTransportasks the server to take the message, reads the answer —250 Message queued as 68bf1ca9e0d2f— checks the response code and drops the line, so an SMTP store had no provider id at all. It also appends the whole conversation to theSentMessage, so the answer was still there to be read, and it is now. On a provider's own relay that id is the provider's: MailerSend documents the id in this line as the same one their webhooks report events under, and since their webhooks carry no headers, no metadata and not theMessage-IDeither, it is the only handle a store sending through them over SMTP will ever get. SMTP2GO and SendGrid answer the same way, and an Exim relay spells itid=. Only the final250is read, because the ones before it are answers about an address rather than about the message, and a server that accepts without naming — plain250 2.0.0 Ok— still answers null rather than having something invented from the line - The send log line carries the provider's id for the message, which is the first thing worth knowing when a delivery webhook cannot be matched to the message it is about
getLastSendId()answers the provider's own id for the message just sent. Every API transport already hands one back — Symfony's Resend, Postmark, SES, SendGrid, Mailgun and MailerSend transports all callSentMessage::setMessageId()with whatever their API returned — and this plugin was collecting it and dropping it on the floor, keeping only the debug string beside it. It matters because it is the same id that provider then names in its delivery webhooks, so it is the one string both ends are certain to agree on. Anything storing it can join a bounce or a delivery report back to the message it is about without needing the provider to echo a custom header, and without needing theMessage-IDthe message left with to survive the trip — which it often does not: a provider running on Amazon SES mints its own on the way out and reports that one. Null on a failed send and on a transport that answers no id at all
-
- A transport that answers a send with the message's own id no longer has that recorded as the provider's id.
Message-IDis an identification header and Symfony answers those with a list of ids rather than a string, so the guard meant to catch this was casting an array and comparing an id against the word "Array" — it had never once fired. Mailgun's API answers a send with the id the message already had, in its wire form with the angle brackets on, so a store on Mailgun recorded its ownMessage-IDas Mailgun's id for the message; the delivery event that arrived then named a third string again, and the rung of the correlation ladder meant to be the most reliable matched nothing. Both halves are fixed: the header is read as the list it is, and the two ids are compared without the brackets, which are a transport's habit rather than a difference in the id.getLastSendId()answers null on a transport that only echoes, which is the honest answer and lets the match fall to theMessage-IDrung that does work - A transport that cannot be built no longer takes the whole site down with it. The mailer is built on
onPluginsInitialized, on every request, long before anything has decided whether this request sends an email — so a provider plugin that throws while naming its DSN does not produce a failed send, it produces a white screen on every page of the site. Including the admin, including the settings form where the missing field would have been filled in, leaving hand-editing YAML over SSH as the only way back. Found by saving the Mailgun settings with the domain still empty, which is an ordinary thing to do on the way to configuring it. Building the transport is now guarded: the reason goes to the Grav log, the site stays up, and the store gets anUnusableTransportthat refuses messages with that reason attached. Deliberately notnull://, which accepts everything and delivers nothing — a store whose transport is broken has to be told, because silently swallowing a customer's order confirmation is the worse of the two failures by a distance
- A transport that answers a send with the message's own id no longer has that recorded as the provider's id.
-
- 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
- Email actions and plugins can now set custom headers on a message with a
-
- The help text under the From, To, CC, BCC and Reply-to fields showed the name-addr example as "Your Name
" in the new admin, which renders help as sanitised HTML and droppedemail@address.org` as an unknown tag. The example's angle brackets are now written as entities so the format the field accepts is actually shown
- The help text under the From, To, CC, BCC and Reply-to fields showed the name-addr example as "Your Name
-
- The Spanish translation now covers every string, up from two, and the messages asking you to configure a 'to' or 'from' address no longer name the opposite one. Thanks to @pmoreno-rodriguez
-
- Email addresses with a stray space around them are no longer thrown away, so a
from,to,cc,bccorreply_tosetting that ends in a space keeps working instead of failing with a confusing "An email must have a From or a Sender header" error. - An address setting containing nothing but spaces now tells you that address needs configuring, rather than failing later with that same confusing error.
- Email addresses with a stray space around them are no longer thrown away, so a
-
- [security] On Grav 2.0, email settings can no longer read your site, system or theme configuration directly, which was a way around the restriction added in 5.0.4 that already stopped them reading it through
config(GHSA-p597-crqc-m349). Requires Grav 2.0.16 or later, which is also where the underlying protection for email settings lives.
- [security] On Grav 2.0, email settings can no longer read your site, system or theme configuration directly, which was a way around the restriction added in 5.0.4 that already stopped them reading it through
-
- [security] On Grav 2.0, Twig in a form's email settings now runs under Grav's content sandbox, so someone who can only edit pages can no longer use an email action to run commands on the server (GHSA-gh8j-q67c-j53f). Requires Grav 2.0.16 or later.
- Email settings can still read your site configuration and this plugin's own address settings, so
{{ config.site.emails.sales }}and{{ config.plugins.email.to }}keep working, but they can no longer read your mail server password or any other plugin's settings. - Grav 1.7 has no Twig content sandbox, so email settings behave there exactly as they did before, and the plugin still runs on the PHP versions that line supports.
-
- Email templates that fail to render (a Twig syntax error, or an unresolved include or extends) are now logged to the email and Grav logs instead of failing silently, so a broken template is much easier to track down.
-
- Button links in HTML emails now keep their white text in email clients that force their own link color, by targeting the
a.btn-primaryanda.btn-secondaryselectors with!important.
- Button links in HTML emails now keep their white text in email clients that force their own link color, by targeting the
-
- Fixed
createAddress()to gracefully handle empty or invalid email addresses instead of throwing RFC 2822 errors - Fixed Twig processing to bypass Grav 1.8's security filter for trusted email config (fixes issues with
{% include %}and field names containingmail)
- Fixed
-
- Added more details for HTTP error logs when using API-based email providers
- Addressed a compatibility issue with Grav 1.8beta
-
- Updated to latest symfony libraries (
v5.4.45) - Added a note about Google App Passwords for authentication in the README.md
- Updated to latest symfony libraries (
- Updated logging methods to support Monolog2+
-
- Update vendor libraries for Mailer to latest
5.4versions - When you send an email you can use
Email::getLastSendMessage()andEmail::getLastSendDebug()methods to get information about the email processing.
- Update vendor libraries for Mailer to latest
-
- Simplified the
Email::processRecipients()logic for readability
- Simplified the
-
- Fix an issue with 2 email addresses provided with 'just' email and no name #176
- Fix for blank subjectlines when using
Message::setSubject()in Twig templates getgrav/grav-plugin-login#299
-
- some recipient handling improvements. e.g. missing
bcc_namethrowing error - Allow overriding of defaults with a form configuration. Use
nullto remove default email configuration
- some recipient handling improvements. e.g. missing
-
- Migrated from
Swiftmailer(which has been deprecated) toSymfony/Mailer, a more modern and extensible mailing library. - Built-in support for
SMTP,SMTPS,SendmailandNative(uses best solution per-platform) engines - Added ability to have 3rd party plugins that provide new engines (e.g.
SendGrid,MailJet, etc.) withSMTP,APIorHTTPStransports for faster and more reliable email sending - More flexible options for email formatting including RFC822
name-addrformat (Joe Bloggs <user@example.com>) and simple arrays ([user@example.com, Joe Bloggs]) - Support
tagsandmetadatainEmail::buildMessage() - Two new events
onEmailEnginesandonEmailTransportDsnto facilitate the integration of custom engines via plugins
- Migrated from
-
- Fixed for multiple recipients #167
- Fix for simple array format with names which wasn't working
-
- Set
sendmailas default engine to provide better fallback for unsupportedmaileroption - Added info about available engine plugins in
README.md
- Set
-
- Migrated from
Swiftmailer(which has been deprecated) toSymfony/Mailer, a more modern and extensible mailing library. - Built-in support for
SMTP,SMTPS,SendmailandNative(uses best solution per-platform) engines - Added ability to have 3rd party plugins that provide new engines (e.g.
SendGrid,MailJet, etc.) withSMTP,APIorHTTPStransports for faster and more reliable email sending - More flexible options for email formatting including RFC822
name-addrformat (Joe Bloggs <user@example.com>) and simple arrays ([user@example.com, Joe Bloggs]) - Support
tagsandmetadatainEmail::buildMessage() - Two new events
onEmailEnginesandonEmailTransportDsnto facilitate the integration of custom engines via plugins
- Migrated from
-
- Require Grav 1.7.32 and Form 6.0.0
-
- Added access email message object inside the twig template file
-
- Fixed twig not being initialized when building an email message
-
- Updated to Swiftmailer
v6.3.0with PHP 8.1 compatibility #157
- Updated to Swiftmailer
-
- Added new
onEmailMessageevent to make object available for editing #150
- Added new
-
- Tweaked default
base.html.twigtemplate to better support dark-mode clients - Latest vendor updates
- Tweaked default
-
- Add missing support for
template:in body array - Added check to process markdown with
text/htmlcontent type only
- Add missing support for
-
- Disable password autocomplete in password field
- Don't save empty string in password field #134
-
- Updated email validator library
-
- Fixed
Invalid resource theme://on CLI commandtest-emailon Grav 1.6.21 and later versions #128
- Fixed
-
- Support an array of multiple emails in
email:form process - Allow form values in email templates
- Support an array of multiple emails in
-
- Added Twig blocks for
contentandfooterinemail/base.html.twigtemplate - Updated
README.mdto reflect working setup for GMail
- Added Twig blocks for
-
- Requires Form Plugin v3.0.3
- Added Russian translation #113
-
- Better fix for missing attachments when sending an email using a form form#333
-
- Put a
try/catcharound email attachments and log any errors rather than hard fail
- Put a
-
- Fixed missing attachments when sending an email using a form form#333
-
- Added new
template:to choose twig template option for email form processing - Moved
buildMessage()andparseAddressValue()to Email object and made public - Refactored the
EmailUtils::sendEmail()to take an array of params or the old param list - Switched to SwiftMailer v.6.1.3 (requires PHP7/Grav 1.6)
- SwiftMailer 6.x compatibility fixes
- Updated various translations
- Added support for Email Queue with Scheduler support
- Code cleanup, composer update
- Added a new
clear-queue-failuresCLI command to flush out failed sends
- Added new
-
- Added backlink for scheduler task
- Added support for
environmentoption toflushqueueCLI command - Fixed mailtrap hostname in README.md
- Disable autocomplete on SMTP
userandpasswordfields
-
- Added new
onEmailSend()event hook before sending #70
- Added new
-
- Added examples of setting up Email plugin with various SMTP providers
- Updated RU language #60
- Updated to SwiftMailer v5.4.8
-
- Now uses a dedicated
logs/email.logfile whendebug: true - Improved the README.txt file with examples, and troubleshooting
- Changed default engine to
sendmailasmailis deprecated and not functioning [swiftmailer#866](https://github.com/swiftmailer/swiftmailer/issues/866}
- Now uses a dedicated
-
- Improved the error message when missing
fromin the configuration - Silently catch malformed email exceptions
- Improved the error message when missing
-
- Inherit options from plugin configuration #39
-
- Also process translation on the email subject getgrav/grav-plugin-comments#38
-
- Updated to SwiftMailer 5.4.5 #45
-
- Added a new
process_markdownoption for emails in forms
- Added a new
-
- Improved the
Utils::sendEmail()method to take the email type as an option
- Improved the
-
- CLI command will fallback to use the
tofrom email plugin config if not provided - Explicit Composer based class loader to fix issues with class case
- CLI command will fallback to use the
-
- Added a new
bin/plugin email test-emailCLI command
- Added a new
-
- Moved Email
Utilsclass from Login to Email plugin - Provide a sample base
email/base.html.twigtemplate for emails
- Moved Email
-
- Fix handling attachments with the updated file upload field
-
- Added Russian translation
- Updated Swiftmailer to 5.4.3 #37
-
- Allow multiple email recipients (comma separated) #31
- Added Danish and Spanish translations
-
- Now includes Swiftmailer v5.4.2 which introduces a number of bug fixes and improvements
-
- Correct
starttlsimplementation, bundled in TLS
- Correct
-
- Added debug option to enable logging on SwiftMailer.
- Updated SwiftMailer from v5.1.0 to v5.4.1.
- Added an option in the Admin settings to enable
starttls
-
- Correctly name TLS in the Admin settings, the label was
TTS(but the value was correctly namedtls)
- Correctly name TLS in the Admin settings, the label was
-
- Allow to send attachments in forms
- Added French translation
-
- Throw an exception when trying to send emails without a
fromortoparameters setup, to intercept less meaningful errors and provide a better description on how to fix the problem - Changed SMTP password in admin to use a password field instead of plain text
- Throw an exception when trying to send emails without a
-
- Added missing
content_typeto email.yaml - Added default values for CC and BCC
- Added missing
-
- Improved documentation of new email params in
README.md - Moved config setting of
mailer.defaulttomailer.engine
- Improved documentation of new email params in