[1.10.0] - 2026-06-13
Security
-
The recipient domain policy now covers every recipient and blocks subdomains. Only the primary recipient was checked against
security.allowed_domains/blocked_domains; cc/bcc addresses were added to the message unchecked, so an allowlist meant to stop outbound leaks was bypassable via a cc field. Every recipient (primary, cc, bcc) is now validated fail-closed — any disallowed (or non-string) entry returns the non-retryableblocked_domainfailure. The blocklist also gains subdomain matching (blockingevil.comnow blockssub.evil.com); the allowlist deliberately stays exact-match, since widening it to subdomains would silently loosen the permitted set. -
Template engine now HTML-escapes all interpolated notification data.
EmailFormatterinterpolated{{variable}}values raw into HTML, so any user-influenced value (display name, message) could inject markup/links into outgoing mail — including password-reset and 2FA messages. Every{{var}}/{{var|default}}interpolation (and the default literal) is now escaped withhtmlspecialchars(ENT_QUOTES | ENT_HTML5). A new raw triple-mustache{{{var}}}syntax exists for slots that intentionally receive pre-rendered HTML — the shipped layout's{{{content}}}is the only such slot.action_url/reset_urlvalues are blanked unless their scheme is http(s) (relative URLs pass; malformed URLs are rejected), sojavascript:/data:payloads can't reach href slots. Plain-text generation is unaffected (htmlToText()already entity-decodes). -
Attachment and embedded-image paths are now confined to allowed directories. Paths from notification data went straight to Symfony's
attachFromPath()/embedFromPath()with at most afile_exists()check, letting any caller who can influence notification data exfiltrate arbitrary readable files (.env, keys) to a recipient of their choosing. A newAttachmentPathValidator— the single chokepoint for both the standard and enhanced send branches — accepts a path only whenrealpath()resolves it inside an allowed base (security.attachment_allowed_paths; default: the application storage directory), with sibling-dir-safe prefix matching (/app/storage-evilcannot pass for/app/storage). A rejected path is a loud, non-retryableinvalid_attachmentfailure with an ERROR log naming the path — never a silent skip. -
No more secrets or payload values in logs and diagnostics. Transport failures logged the entire notification payload (OTP pins, password-reset tokens/URLs, PII) — values the log sink's key-name-based redaction cannot catch; the failure log now records only payload keys plus the
subject/type/template_nameidentifiers.EmailNotificationProvider::getExtensionInfo()returned the full merged mail config (SMTP/API credentials) to any diagnostic surface; it now returns a credential-free summary (default mailer, transport type, from address, boolean feature flags). -
Misconfigured transport no longer silently discards mail while reporting success.
EmailChannel::createTransport()fell back to thenull://nulltransport on a missing SMTP host, missing provider-bridge credentials, or any transport-factory failure (including a broken failover chain) —sendNotification()then returned success while every message vanished. Each path now throwsTransportMisconfiguredException, surfaced as a non-retryabletransport_misconfiguredfailure result with an ERROR log (config keys only — never credential values). Explicitly configured null sinks (transport: 'null'/null://DSN) keep working. Also hardened DSN construction: SMTPhostis validated against a hostname/IP pattern before interpolation (rejects smuggled authorities likesmtp.legit.com@evil.com),portis cast to int, the Brevo SMTP username is URL-encoded like the password, and disabling TLS peer verification now logs a warning.
Fixed
- The enhanced template send path now honors its template name and keeps cc/bcc/reply-to.
EnhancedEmailFormatter::buildEmailFromTemplate()ignored its$templateNameparameter —data['template']acted only as an accidental feature flag while the actual template came fromdata['type']/data['template_name'](usually the default). The name is now fed into the parent's selection key (an explicitdata['template_name']still wins). The enhanced branch ofEmailChannel::createEmail()also returned early before the standard branch's cc/bcc/reply-to handling, silently dropping them; both branches now apply cc/bcc (policy-validated beforehand) and configreply_tothrough one shared helper so they cannot drift. - One channel, one engine: removed the provider's dead parallel channel wiring.
EmailNotificationProvider::initialize()built a secondEmailChannelwith the plainEmailFormatterand the opposite config-merge precedence to the live DI channel (the framework never invokes that path — it only stores the provider for beforeSend/afterSend hooks). The provider now receives the shared DI-built channel and registers that same instance; its merged config copy is documented as hook/diagnostics-only.isEmailProviderConfigured()also validated against transport names that don't exist (ses,mailgun,sendgrid,postmark) so credential validation never ran for any API provider — it now switches on the real values (ses+api,mailgun+api,sendgrid+api,postmark+api,brevo+api,brevo+smtp), checks the credential keys each factory actually requires (Mailgun:key, notsecret), passes credential-less transports (null/log/array), and fails closed on unknown transports. The provider'sinitialize()failure log now records config keys only, never values.
Added
logging.enabledconfig key (envMAIL_LOG_RESULTS, defaultfalse). The provider'safterSend()result logging was gated on this key but it was defined nowhere, making the feature unreachable; it now works and logs recipient/subject/type only (no payload values).
Removed
TransportFactory::createRoundRobin()— dead code with zero callers and no corresponding config surface.- Dead config blocks that nothing reads:
queue.*(the extension manages no queue),events.*(no event dispatch exists in the extension),templates.processing.*(minify_html/inline_css/auto_text_versionwere loaded but never applied — the loading lines are gone too), and the unreaddebug.log_all_emails/preview_mode/test_emailkeys (debug.enabledstays). EmailChannel::getQueueSize()— zero callers, and it read the framework-levelqueuekey instead of any extension config.EmailNotificationProvider::getMetrics()— zero callers, not part of theNotificationExtensioncontract, and its hand-rolled SQL aggregations over thenotificationstable were never verified against the real schema. Delivery metrics are owned by the framework'sNotificationMetricsService(viaNotificationService::getMetricsService()), fed by the structuredNotificationResultthis channel already returns per send.
Documentation
- README aligned with actual behavior. Removed claims for features that don't exist (queue integration/monitoring, round-robin transport, rate limiting, template caching,
/health/email–/metrics/emailendpoints, atest:emailcommand); corrected env vars (EMAIL_DEBUG→MAIL_DEBUG, dropped unreadMAIL_QUEUE_*/MAIL_TEMPLATE_CACHE*/MAIL_RATE_LIMIT_PER_MINUTE); template list corrected to the 6 shipped templates includingtwo-factor-pin. Documented this round's behavior: automatic HTML escaping with{{{var}}}raw opt-out, URL scheme validation, the cc/bcc + subdomain domain-policy semantics, attachment path confinement, thetransport_misconfigured/invalid_attachmentfailure codes, andMAIL_LOG_RESULTS.