Skip to content

v1.10.0

Latest

Choose a tag to compare

@MichaelSowah MichaelSowah released this 13 Jun 16:22
40f7fa8

[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-retryable blocked_domain failure. The blocklist also gains subdomain matching (blocking evil.com now blocks sub.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. EmailFormatter interpolated {{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 with htmlspecialchars(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_url values are blanked unless their scheme is http(s) (relative URLs pass; malformed URLs are rejected), so javascript:/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 a file_exists() check, letting any caller who can influence notification data exfiltrate arbitrary readable files (.env, keys) to a recipient of their choosing. A new AttachmentPathValidator — the single chokepoint for both the standard and enhanced send branches — accepts a path only when realpath() resolves it inside an allowed base (security.attachment_allowed_paths; default: the application storage directory), with sibling-dir-safe prefix matching (/app/storage-evil cannot pass for /app/storage). A rejected path is a loud, non-retryable invalid_attachment failure 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_name identifiers. 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 the null://null transport 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 throws TransportMisconfiguredException, surfaced as a non-retryable transport_misconfigured failure 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: SMTP host is validated against a hostname/IP pattern before interpolation (rejects smuggled authorities like smtp.legit.com@evil.com), port is 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 $templateName parameter — data['template'] acted only as an accidental feature flag while the actual template came from data['type']/data['template_name'] (usually the default). The name is now fed into the parent's selection key (an explicit data['template_name'] still wins). The enhanced branch of EmailChannel::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 config reply_to through one shared helper so they cannot drift.
  • One channel, one engine: removed the provider's dead parallel channel wiring. EmailNotificationProvider::initialize() built a second EmailChannel with the plain EmailFormatter and 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, not secret), passes credential-less transports (null/log/array), and fails closed on unknown transports. The provider's initialize() failure log now records config keys only, never values.

Added

  • logging.enabled config key (env MAIL_LOG_RESULTS, default false). The provider's afterSend() 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_version were loaded but never applied — the loading lines are gone too), and the unread debug.log_all_emails/preview_mode/test_email keys (debug.enabled stays).
  • EmailChannel::getQueueSize() — zero callers, and it read the framework-level queue key instead of any extension config.
  • EmailNotificationProvider::getMetrics() — zero callers, not part of the NotificationExtension contract, and its hand-rolled SQL aggregations over the notifications table were never verified against the real schema. Delivery metrics are owned by the framework's NotificationMetricsService (via NotificationService::getMetricsService()), fed by the structured NotificationResult this 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/email endpoints, a test:email command); corrected env vars (EMAIL_DEBUGMAIL_DEBUG, dropped unread MAIL_QUEUE_*/MAIL_TEMPLATE_CACHE*/MAIL_RATE_LIMIT_PER_MINUTE); template list corrected to the 6 shipped templates including two-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, the transport_misconfigured/invalid_attachment failure codes, and MAIL_LOG_RESULTS.