Changelog
[Unreleased]
Fixed
- Mail Components:
<mail-button>emitted itsurlthrough theattrescaper context, which is meant for unquoted attribute values and encodes every non-alphanumeric character. Thehrefcame out ashttps://example.com— which email clients still follow, but which hides the link from anything post-processing the rendered HTML by matchinghref="http...", such as click tracking or link rewriting. The href is double-quoted, so thehtmlcontext is the correct one: it neutralises the characters that could end the attribute and leaves the URL readable. - Mail Components:
<mail-panel>'scolornever reached the email. It was emitted through thecssescaper, which encodes#as\23, producingbackground-color: \23 eff6ff— an ident token where a hash token is required, so the declaration was invalid and dropped. Panels rendered with no background at all, including at the default colour. The value is now validated as a hex colour or colour keyword, falling back to the default when it is neither: for a value that lands in a CSS property position, escaping and rendering are mutually exclusive, so validation is the only workable approach. - Mail Components: each component now renders with
saveDataoff. CodeIgniter's renderer carries view data betweenrender()calls, so an optional attribute set on one component leaked into the next component that omitted it — a<mail-panel>with nocolorpicked up the colour of an earlier panel in the same email.
Changed
- BREAKING: the mailer configuration class is now
Myth\Postal\Config\Mailer(wasMyth\Postal\Config\Email), andMailerManagerresolves it by its short name. The old name collided with the framework's legacyConfig\Email, which forced resolution by fully-qualified class name — and that form never consults an application subclass, so the documented override path could not work. Applications that published aConfig\Emailextending the package config should rename it toConfig\Mailerand extendMyth\Postal\Config\Mailer. The framework's own legacyConfig\Email, used byLegacyEmailAdapterandspark email:test, is unaffected. See ADR 0001.
Added
- Markdown Mailables: author an email body as CommonMark markdown via the
markdown()global helper orMailable::markdown($view, $data), which resolves a view to raw markdown, converts it (service('markdown'), aMarkdownRendererbuilt from aLeague\CommonMarkEnvironment), wraps the result in a sharedLayout(LayoutRenderer, single-slot, applied after conversion — falls back toConfig\Postal::$defaultLayout, or overridden per-Mailable withMailable::layout()), and inlines the Layout's CSS for email-client compatibility. Also derives a plain-text fallback directly from the markdown source (MarkdownString::text()) rather than reverse-engineering it from HTML. Ships two pre-styled Mail Components —<mail-button url="...">and<mail-panel color="...">— resolved by a real CommonMark parser/renderer extension (MailComponentExtension), not regex or DOM post-processing. Addsleague/commonmarkandtijsverkoyen/css-to-inline-stylesas hard dependencies.ViewPublisher, discovered and run byspark publishalongsideConfigPublisher, copies the default Layout and Mail Component views intoapp/Views/mail/, never overwriting existing files, so host apps can restyle them freely. Raw-HTML passthrough stays at its default throughout, so markdown views inherit CI4's usualesc()-it-yourself trust model; see Markdown Mailables.make:mailablegains a--markdownflag that scaffolds a starter markdown view alongside abuild()calling->markdown()instead of->html(). -
spark publishwritesapp/Config/Mailer.php, aConfig\Mailerextending the package's config, which Postal then resolves in preference to its own default. Re-running publish never overwrites an existing file. -
Failover transport (
failovermailer): a composite that tries an ordered list of child mailers (named under achainkey) and falls through to the next on failure — returning the first success and reporting failure only when every child fails. A child that throws is treated as a failure so the chain advances.MailerManagerresolves the child mailers by name and hands the already-built transports to the composite. A failover mailer with no children throws aPostalException. - Automatic inline images: the renderer scans the HTML body for
<img>sources and embeds the embeddable ones —data:image URIs (decoded in memory) and local image file paths (read at render) — rewriting each reference to acid:URL and de-duplicating identical sources. Remote (http(s)://) and existingcid:sources are left untouched, and only files that sniff as images are read. On by default; disable per message withEmail::$autoEmbedImages = false. AddsAttachment::embedData(). - Amazon SES transport (
sesmailer): delivers through the officialaws/aws-sdk-phpSesV2 client (SigV4 signing and HTTP owned by the SDK). Sends structured Simple content by default and switches to raw MIME for attachments/inline images, HTML without an explicit text body, or whenforceRawis set. MapsEmail::metadata()onto SES EmailTags (dropping and debug-logging tags that break SES's character rules), applies an optionalconfigurationSet, and returns the SES message id.aws/aws-sdk-phpis an optional dependency (install it withcomposer require aws/aws-sdk-php). Email::metadata(key, value)builder for provider tags, mapped by API transports onto their tagging feature and ignored by the others.- Attachments and inline images:
Email::attach()(file by path, read lazily at render),Email::attachData()(raw bytes), andEmail::embedImage()(inline CID image referenceable from HTML), backed by theAttachmentvalue object. The renderer nestsmultipart/mixed(attachments) aroundmultipart/related(inline images) around the existingmultipart/alternative, with base64 parts chunked at 76 columns and part headers sanitised against filename injection. - Core send pipeline: compose an
Emailand send it through a transport viaservice('mailer'), receiving aSendResult. Emailmessage builder withfrom/replyTo/to/cc/bcc/subject/html/text/header/priority/returnPath(mutable, chainable).Addressvalue object that parses and renders"Name <email>".SendResultwithok()/fail()/cancelled()factories.TransportInterfaceand theNullTransportimplementation.MailerManager(resolves the default and named mailers fromConfig\Mailervia an extensible transport map, lazily and cached) and a minimalMailerthat clones the message at the dispatch boundary.Config\Mailerconfiguration and theservice('mailer')service.MessageRendererthat serialises anEmailinto a raw RFC 5322 / MIME string:text/plain, ormultipart/alternativewhenever HTML is present (with an automatically generated HTML→text fallback when no text body is set). Emits custom headers,Return-Path/Sender, and non-defaultX-Priority; applies RFC 2047 header encoding and quoted-printable body encoding (7-bit clean, within the 998-octet SMTP limit); strips CR/LF to prevent header injection; exposes the rendered header set viaheaders().LogTransportand the built-inlogmailer, which render the message and write the full MIME to a PSR-3 log channel (default leveldebug) instead of delivering it.- Email lifecycle events fired around every send:
email.composing(at the start),email.sending(immediately before the transport — returningfalsecancels the send and yieldsSendResult::cancelled()), andemail.sent/email.failedafterwards (each receiving theEmailandSendResult). All emission is gated behindConfig\Mailer::$fireEvents(defaulttrue). SendmailTransport/MailTransportand thesendmailandmailmailers, which hand the rendered MIME to a local MTA:sendmailpipes it to the configured binary (path) with-oi -t, andmailsplits it across PHP's nativemail(). Both deliverBccvia a header the MTA strips, set the envelope sender (-f) from the return path orFrom(validated and shell-escaped), and talk through theSendmailProcess/MailFunctionseams for testing.LegacyEmailAdapter, a drop-in replacement for CodeIgniter 4's email service.service('email')now returns the adapter, which exposes the full legacy fluent API (setFrom/setTo/setCC/setBCC/setSubject/setMessage/setAltMessage/setMailType/setHeader/setPriority/setWordWrap/setProtocol/attach/send/printDebugger/batchBCCSend/validation helpers) on top of the newEmailbuilder andMailer. Honors the legacy flatConfig\Emailkeys (protocol,SMTP*,mailPath,mailType,wordWrap/wrapChars,priority,BCCBatch*,DSN) so existing apps need no configuration changes. Invalid addresses are swallowed (send()returnsfalseand never throws). For CI4 parity,Return-PathandReply-Todefault toFrom, and sends flow through the event pipeline (aemail.sendinglistener can cancel). See Legacy Compatibility.- Optional hard word-wrap for the plain-text part: set
Email::$wordWrap(and$wrapChars, default 76) andMessageRendererwraps at word boundaries, leaving long space-less tokens (e.g. URLs) intact. Mailer::fake()and theFakeTransporttest double: swaps the boundservice('mailer')for an in-memory recorder (no network, no real transport) and returns it. Exposes content-matcher assertions —assertSent(),assertSentTo(),assertNotSent(),assertNothingSent(),assertSentCount()— andsent()for inspecting the recorded messages. See Testing Mail.Mailable, an abstract class-based email definition: compose the message inbuild()(which runs lazily at send time) via protectedfrom/to/subject/html/texthelpers, pick a named mailer withtransport(), andsend()to route throughservice('mailer'). Each send tags the message with the Mailable's class. Themake:mailableSpark command scaffolds a class intoapp/Mails/(--forceoverwrites).FakeTransport::sent()andassertSent()now also accept a Mailable class-string (with an optional closure filter) so tests can assert by type. See Mailables.