nowo-tech / mail-kit-bundle
Runtime-configurable mailer for Symfony on top of Instance Settings: DSN and sender validation, SSRF-safe hosts, test e-mail, Resend key check, sent-mail log with retention and an AuthKit mail-ready adapter.
Package info
github.com/nowo-tech/MailKitBundle
Type:symfony-bundle
pkg:composer/nowo-tech/mail-kit-bundle
Fund package maintenance!
Requires
- php: >=8.2 <8.6
- doctrine/dbal: ^3.8 || ^4.0
- doctrine/doctrine-bundle: ^2.13 || ^3.0
- doctrine/orm: ^3.3
- doctrine/persistence: ^3.1 || ^4.0
- nowo-tech/controller-kit-bundle: ^2.3.1
- nowo-tech/form-kit-bundle: ^2.17
- nowo-tech/instance-settings-bundle: ^1.1
- nowo-tech/outbound-url-guard-bundle: ^1.3
- nowo-tech/ui-kit-bundle: ^2.21
- psr/clock: ^1.0
- psr/log: ^2.0 || ^3.0
- symfony/clock: ^7.4 || ^8.0
- symfony/config: ^7.4 || ^8.0
- symfony/console: ^7.4 || ^8.0
- symfony/dependency-injection: ^7.4 || ^8.0
- symfony/event-dispatcher: ^7.4 || ^8.0
- symfony/form: ^7.4 || ^8.0
- symfony/framework-bundle: ^7.4 || ^8.0
- symfony/http-client: ^7.4 || ^8.0
- symfony/http-foundation: ^7.4 || ^8.0
- symfony/http-kernel: ^7.4 || ^8.0
- symfony/mailer: ^7.4 || ^8.0
- symfony/mime: ^7.4 || ^8.0
- symfony/polyfill-intl-idn: ^1.31
- symfony/polyfill-intl-normalizer: ^1.31
- symfony/rate-limiter: ^7.4 || ^8.0
- symfony/routing: ^7.4 || ^8.0
- symfony/security-core: ^7.4 || ^8.0
- symfony/security-csrf: ^7.4 || ^8.0
- symfony/service-contracts: ^3.5
- symfony/translation-contracts: ^3.5
- symfony/twig-bundle: ^7.4 || ^8.0
- symfony/uid: ^7.4 || ^8.0
- symfony/validator: ^7.4 || ^8.0
- twig/extra-bundle: ^3.12
- twig/string-extra: ^3.12
- twig/twig: ^3.12
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.0
- igor-php/igor-php: ^0.10.1
- nowo-tech/auth-kit-bundle: ^1.31
- nowo-tech/phpstan-frankenphp: ^1.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^11.5
- rector/rector: ^2.0
- symfony/amazon-mailer: ^7.4 || ^8.0
- symfony/browser-kit: ^7.4 || ^8.0
- symfony/css-selector: ^7.4 || ^8.0
- symfony/dom-crawler: ^7.4 || ^8.0
- symfony/mailgun-mailer: ^7.4 || ^8.0
- symfony/messenger: ^7.4 || ^8.0
- symfony/phpunit-bridge: ^7.4 || ^8.0
- symfony/scaleway-mailer: ^7.4 || ^8.0
- symfony/security-bundle: ^7.4 || ^8.0
- symfony/sendgrid-mailer: ^7.4 || ^8.0
- symfony/translation: ^7.4 || ^8.0
- symfony/yaml: ^7.4 || ^8.0
Suggests
- nowo-tech/auth-kit-bundle: Gate magic-login / password-reset UI on a deliverable mailer (OutboundMailReadyCheckerInterface adapter)
- symfony/flex: For automatic recipe installation
- symfony/resend-mailer: Required for resend+api:// DSNs and the optional Resend settings section
- symfony/security-bundle: Required for the admin UI unless security.allow_unauthenticated is true (dev / demo only)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-01 13:29:21 UTC
README
⭐ Found this useful? Install from Packagist · Give it a star on GitHub so more developers can find it.
Mail Kit Bundle — the mailer DSN, sender and test e-mail an operator edits without a deploy. It contributes a mailer (and optional resend) section to Instance Settings — DSN encrypted at rest, sender address and name, Reply-To, test recipient — and decorates the Symfony mailer so every send resolves the DSN at runtime (framework MAILER_DSN as fallback), also for messages queued to Messenger. DSNs are validated (no sendmail / native / null, SSRF-safe hosts through OutboundUrlGuard rules), senders must be on a public domain, a "send a test e-mail" action and CLI answer with the transport's own error, a Resend key check calls the API with a deadline, and a sent-mail log (envelope only by default, retention purge, right-to-erasure command) has a UiKit admin page. isOutboundMailReady() gates AuthKit magic-login / password-reset. Tested on Symfony 7.4, 8.0 and 8.1 · PHP 8.2+ (Symfony 8.x requires PHP 8.4+).
This bundle is FrankenPHP worker mode friendly.
The transport built from the stored DSN is cached per DSN and dropped on kernel.reset and on SettingsChangedEvent; the DSN itself is read on every send, so a change saved in one worker is used by every worker on its next message. See docs/FRANKENPHP-WORKER-AUDIT.md.
Screenshots
Mailer section in Instance Settings: encrypted DSN, sender and a test e-mail that reports the transport answer |
Sent-mail log: status, recipients, tag and transport per message (no bodies by default) |
Features
- Runtime DSN —
RuntimeTransportdecoratesmailer.transports(used by the framework mailer and the MessengerSendEmailMessagehandler): the DSN comes from themailer.dsnsetting (or the Resend key) on every send, the framework transport (MAILER_DSN) is the fallback, a message with anX-Transportheader keeps its named transport. The built transport is cached per DSN and reset onkernel.reset/SettingsChangedEvent(worker safe). Replace the source with your ownMailerDsnProviderInterface. MailerInterfacedecorator — fills the runtime From (mailer.from/mailer.from_name) and Reply-To when a message has none, before the framework mailer builds the envelope or queues it.- Settings sections —
mailer: DSN (encrypted secret), sender address and name, Reply-To, test recipient; optionalresend: switch, API key (encrypted), sender. Validated on save and import; never rendered back. - DSN validation — allow-listed schemes (SMTP + common provider bridges;
sendmail,nativeandnullalways refused), parse and build check (missing bridge detected), host SSRF guard: private / loopback / link-local / reserved / cloud-metadata addresses andlocalhost/*.local/*.internalnames refused, host names resolved with a bounded lookup;allowed_hostsfor an internal relay. A refused DSN fails the send (no silent fallback). - Redacted audit —
MailerDsnAudit(scheme://host[:port], never credentials) in logs, the admin header andSettingsChangedEventaudit lines; transport errors are scrubbed of credentials before they are stored or shown. - Test e-mail — "Send a test e-mail" next to the Save button (and
nowo:mail-kit:send-test): synchronous, through the current configuration, CSRF + MailKit access + Instance Settings section access, per-user rate limit, the transport's answer in the flash. - Resend — "Check the API key" (
GET /domains, timeout, no redirects; sending-only keys recognised) and a Resend test e-mail. - Sent-mail log — one row per message (queued → sent / failed, attempts, provider id, size, tag); bodies only with
log.store_bodies(values passed throughMailHeaders::redact()masked); UiKit list (filters, server-side sortable columns, pagination) and detail pages; streamed CSV export of the filtered, sorted log (formula-injection safe, capped, bodies only on triple opt-in);nowo:mail-kit:log:purge(retention, keyset batches) andnowo:mail-kit:log:forget <email>(right to erasure). - Readiness —
OutboundMailReadiness::isOutboundMailReady(), Twignowo_mail_kit_outbound_mail_ready()/nowo_mail_kit_status(), and an AuthKitOutboundMailReadyCheckerInterfaceadapter wired automatically when AuthKit is installed. - Fail-closed wiring — the container refuses to compile without Instance Settings or the framework mailer, with the UI on but no form CSRF, with an empty role list, or with
allow_unauthenticatedoutsidekernel.debug.
Installation
composer require nowo-tech/mail-kit-bundle
With Symfony Flex the recipe adds config/packages/nowo_mail_kit.yaml and the route import. Without Flex, register NowoInstanceSettingsBundle (and its dependencies) and NowoMailKitBundle, then import the routes — see docs/INSTALLATION.md.
# config/routes/nowo_mail_kit.yaml nowo_mail_kit: resource: . type: nowo_mail_kit
Create the log tables with a migration (php bin/console doctrine:migrations:diff) and protect the prefix:
# config/packages/security.yaml security: access_control: - { path: ^/admin/mail, roles: ROLE_ADMIN }
Quick start
# config/packages/nowo_mail_kit.yaml nowo_mail_kit: sender: default_from: 'noreply@example.com' # default of the mailer.from setting log: retention_days: 30
Keep MAILER_DSN as the bootstrap fallback (null://null is fine), open Settings → Mailer (/admin/settings/sections/mailer), store a DSN such as smtp://user:password@smtp.example.com:587, click Send a test e-mail. Every MailerInterface::send() now goes through it:
public function __construct(private MailerInterface $mailer) {} $this->mailer->send((new Email())->to($user->getEmail())->subject('Welcome')->text('…')); // From filled from the settings
{% if nowo_mail_kit_outbound_mail_ready() %}<a href="{{ path('app_reset_password') }}">Forgot your password?</a>{% endif %}
Schedule php bin/console nowo:mail-kit:log:purge daily.
Requirements
- PHP 8.2+ (
>=8.2 <8.6; Symfony 8.x requires PHP 8.4+) - Symfony 7.4, 8.0 or 8.1 (FrameworkBundle with the mailer, Form, Validator, Security CSRF, RateLimiter, HttpClient, TwigBundle; SecurityBundle for the admin UI)
nowo-tech/instance-settings-bundle^1.0 (settings store, encrypted secrets, settings UI), Doctrine ORM 3.3+,nowo-tech/ui-kit-bundle^2.17.3,nowo-tech/form-kit-bundle^2.17,nowo-tech/outbound-url-guard-bundle^1.3,twig/extra-bundle- Optional:
symfony/resend-mailer(Resend),nowo-tech/auth-kit-bundle(readiness adapter)
Configuration
Every key, its default and its validation are documented in docs/CONFIGURATION.md. Safe defaults: decorated mailer, SMTP + provider schemes only, private hosts refused, public-domain senders, 5 test e-mails per 10 minutes, log on without bodies, 30-day retention, UI under /admin/mail for ROLE_ADMIN only.
Usage
Runtime DSN and fallback, sender, tags and redaction, readiness and AuthKit, the test e-mail, Resend, the sent-mail log and its retention: docs/USAGE.md.
Demo
demo/symfony8— Symfony 8.1 (FrankenPHP PHP 8.5, worker mode) with MySQL and Mailpit; app on host port 8085, Mailpit UI on 8087. The mailer section points at Mailpit; the home page sends a few messages and the log lists them.
Runtime mode is FRANKENPHP_MODE (worker default, or classic) — see docs/DEMO-FRANKENPHP.md.
make -C demo up-symfony8 # http://localhost:8085 (Mailpit: http://localhost:8087) make -C demo/symfony8 setup # encryption key, schema, demo settings and messages make test-e2e # Playwright e2e against the running demo make demo-screenshots # refresh docs/images/demo/*.webp make demo-smoke # boot, HTTP 200, down (REQ-TEST-011)
Development
make up
make install
make test
make cs-check
make phpstan
make igor
make test-coverage-100
make release-check
Documentation
- Installation
- Configuration
- Usage
- Contributing
- Code of Conduct
- Changelog
- Upgrading
- Release
- Security
- Engram
- Spec-driven development
- GitHub Spec Kit
Additional documentation
- Templates (UiKit page contract)
- Architecture
- Roadmap
- PSR evaluation (REQ-CS-007)
- Demo (FrankenPHP)
- FrankenPHP worker audit (kernel not reset)
- GitHub Actions CI requirements
Tests and coverage
- Tests: PHPUnit (PHP) — unit tests plus kernel integration tests (decorated mailer end to end with Messenger, settings-stored DSN, fallback, refused DSNs, sent-mail log on SQLite and, with
make test-with-db, on the root-compose MySQL 8.4, admin UI with SecurityBundle and CSRF, Resend check with a mocked API, rate limit, CLI, opt-in routes, invalid configuration) and the UiKit page contract test; Playwright e2e on the demo - PHP: 100% (1724/1724 statements, 1973/1973 elements, 224 tests; gate enforced in CI by
composer test-coverage-100, no exclusions) - TS/JS: N/A
- Python: N/A
License and author
MIT · Nowo.tech · Héctor Franco Aceituno


