nvl / mail-notifications
Provider-neutral Laravel mail tracking, scheduling, MailerSend integration, and tokenized presentation
Requires
- php: ^8.3
- ext-filter: *
- ext-json: *
- ext-mbstring: *
- laravel/framework: ^13.0
- nesbot/carbon: ^2.72 || ^3.0
- nvl/core: ^2.0
- nvl/settings: ^2.0
- nvl/tenancy: ^2.0
- symfony/mailer: ^7.0 || ^8.0
- symfony/mime: ^7.0 || ^8.0
Requires (Dev)
- larastan/larastan: ^3.10
- laravel/pint: ^1.27
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-26 07:35:38 UTC
README
For support, open an issue. For vulnerabilities, use private reporting. See Contributing.
See the installation and publishing guide for Composer setup, configuration, migration ownership, and agent skills.
Quick reference
| Item | Value |
|---|---|
| Installed through | composer require nvl/mail-notifications:^2.0 |
| Module identifier | nvl/mail-notifications |
| PHP namespace | Nvl\MailNotifications |
| Service provider | Nvl\MailNotifications\Providers\MailNotificationsServiceProvider |
| Configuration | config/mail-notifications.php |
Provider-neutral, explicitly opt-in outbound mail tracking for Laravel.
Purpose
nvl/mail-notifications records the lifecycle of selected Laravel Mailables
without replacing Laravel Mail or selecting a transport. Ordinary Mailables are
unaffected. A message participates in tracking only when it implements
TrackableMessage and uses TracksMailDelivery.
The package owns a tokenized default Laravel Markdown presentation, correlation identifiers, normalized effective recipients, pending and accepted delivery state, provider-neutral lifecycle transitions, durable provider-event idempotency, privacy-safe metadata, after-commit events, database-enforced status allowlists, optional protected JSON storage, bounded history anonymization, and optional environment-safe recipient interception. Provider SDKs, application mail content, sender policy, business notification policy, concrete domain models, permissions, routes, and dashboards stay outside the package.
The default theme loads after application-configured mail.markdown.paths, so
an existing host override remains authoritative. Publish the package views when
the host wants editable copies at Laravel's conventional override path.
Requirements and installation
- PHP 8.4 or newer
- Laravel 13
- SQLite
- PostgreSQL supported by the installed Laravel version
- MySQL 8.0.16 or newer
- MariaDB 10.3 or newer through Laravel's
mariadbdriver, with the session variablecheck_constraint_checksenabled
composer require nvl/mail-notifications:^2.0 php artisan migrate
Before migrating an existing schema, review configuration and the migration ownership choice below. Publish only the resources the application needs; config changes must be in place before migration:
php artisan vendor:publish --tag=mail-notifications-config php artisan vendor:publish --tag=mail-notifications-skills php artisan vendor:publish --tag=mail-notifications-mail-views
The skills are agent guidance. Mail views are editable overrides; package views work without publishing them.
For a legacy-schema cutover, publish the versioned manifest template and keep tracking and scheduling disabled until the import and strict doctor pass:
php artisan vendor:publish --tag=mail-notifications-adoption php artisan nvl:mail-notifications:adopt mail-notifications.adoption.json --stage php artisan nvl:mail-notifications:adopt mail-notifications.adoption.json --stage --apply php artisan migrate php artisan nvl:mail-notifications:adopt mail-notifications.adoption.json php artisan nvl:mail-notifications:adopt mail-notifications.adoption.json --apply php artisan nvl:mail-notifications:doctor --strict --format=json
Both adoption phases are dry runs without --apply. Staging validates every
rename before detaching explicitly declared host foreign keys and moving
canonical-name legacy tables aside. Import requires the complete package
schema, validates exact counts, status and notifiable maps, safe metadata and
provider-event mappings, and every scheduled factory alias/version before one
data transaction starts. It clears legacy scheduler locks, reconciles imported
identities, restores declared host foreign keys, and drops source tables only
when drop_sources is explicitly true. Keep that option false through the
rollback window.
Package discovery registers MailNotificationsServiceProvider. Migrations
load automatically unless mail-notifications.migrations.enabled is false.
Choose exactly one migration owner:
- Automatic vendor loading (default): leave
mail-notifications.migrations.enabled=true, do not publishmail-notifications-migrations, and runphp artisan migrate. - Host-owned published migrations: publish
mail-notifications-migrations, setmail-notifications.migrations.enabled=falsebefore migrating, and maintain the published files as application migrations.
Never run both sources. Laravel retimestamps files published through the migration tag. php artisan nvl:mail-notifications:doctor reports a warning when automatic loading remains enabled and database/migrations contains a timestamp-independent name matching a package migration; --strict promotes that warning to failure. Keep the configured storage connection and table names stable between forward migrations. The first-release creator migrations install queue-failure
identity, privacy markers, retention indexes, and exact status invariants as one
complete schema contract. This unpublished package has no corrective
queue/status/privacy migration chain to replay.
PostgreSQL, MySQL 8.0.16+, and MariaDB 10.3+ through Laravel's mariadb
driver receive named CHECK constraints for notification status, normalized
provider-event type, and scheduled-message status. SQLite receives equivalent
named INSERT and UPDATE triggers without rebuilding tables. The
compatibility preflight rejects pre-existing configured tables unless they
expose the complete current columns, exact named indexes, status allowlists,
and ownership linkage and the creator has one authoritative migration-history
record.
The preflight and creator migrations fail before schema mutation when the configured database cannot prove those invariants are enforced. Laravel connection table prefixes are respected, and PostgreSQL schema-qualified table names are supported; ownership checks compare the physical owner schema and table. Run the strict doctor on the same connection/session used by deployment and workers.
Package migration down() methods are intentionally no-ops: ordinary
migrate:rollback retains mail history tables, columns, indexes, constraints,
and SQLite invariant triggers and is not an uninstall mechanism. Laravel still
removes the corresponding migration history rows. If a package migration is
rolled back accidentally, do not rerun it against retained tables with
ambiguous history. Either restore the exact
migration-history records from the deployment backup after verifying the
schema, or disable package migrations and adopt the retained schema through
application-owned forward migrations.
The read-only compatibility preflight validates columns, portable types, nullability, lengths, defaults, privacy-marker shape, primary/unique constraints, exact status allowlists, provider-event ownership, and named operational indexes before a creator can encounter an existing table. A compatible pre-existing table pair is still rejected unless the exact package creator is already recorded in migration history: otherwise its ownership and future schema evolution have no single writer. Hosts that own an existing pair must disable package migrations and baseline ownership intentionally. A notification-only schema is rejected for the same reason: the preflight cannot safely distinguish an interrupted package creation from a host-owned table, so it will not let the creator complete that partial schema.
php artisan vendor:publish --tag=mail-notifications-migrations php artisan migrate
Explicit opt-in and opt-out
Implement TrackableMessage and use TracksMailDelivery on a Mailable:
use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use Illuminate\Mail\Mailable; use Nvl\MailNotifications\Contracts\TrackableMessage; use Nvl\MailNotifications\Laravel\Concerns\TracksMailDelivery; use Nvl\MailNotifications\ValueObjects\TrackingContext; final class ReceiptMail extends Mailable implements TrackableMessage { use TracksMailDelivery; public function trackingContext(): TrackingContext { return TrackingContext::forCategory('receipt'); } public function envelope(): Envelope { return new Envelope(subject: 'Receipt'); } public function content(): Content { return new Content(markdown: 'mail.receipt'); } }
That class opts in by default. Disable one delivery fluently:
Mail::to($recipient)->send( (new ReceiptMail)->withoutMailTracking(), );
Call withMailTracking() to re-enable the same instance. Mailables without the
contract and concern never participate.
The concern's forNotifiable() and withTrackingMetadata() overrides apply to
one send() invocation. They survive queue serialization, then clear after the
delivery attempt even when delivery throws, so manually reusing a Mailable
cannot inherit another delivery's host association or metadata. Reapply them
explicitly before another manual send when that context is still intended.
Queued Mailables also serialize a random, non-sensitive queue_reference.
Normal delivery attempts retain their own correlation IDs, while Laravel's
terminal queued-email failure callback uses the queue reference to avoid
duplicating a row already failed or accepted by the send path. The original
Mailable instance drops that reference after enqueue, so later manual reuse
cannot join the queued copy's lifecycle.
Exclude whole Laravel mailers when tracking is not wanted for a transport:
MAIL_NOTIFICATIONS_EXCLUDED_MAILERS=smtp,log
Mailer exclusions apply after the Mailable opts in. SMTP and every other Laravel transport continue to send normally. If SMTP is not excluded, the standard Symfony transport message identifier provides provider-neutral acceptance correlation. Provider adapters may supply a more specific message identifier resolver without changing core.
Tracked delivery requires the selected mailer to resolve to Laravel's concrete
Illuminate\Mail\Mailer class or a subclass. This lets the package observe the
final message immediately at the Symfony transport boundary, independent of
Laravel event-listener order. Custom Symfony transports inside that mailer are
fully supported. A class that only decorates Illuminate\Contracts\Mail\Mailer
cannot expose that boundary safely; exclude it, move the decorator to the
Symfony transport layer, or use fail_open to deliver without a tracking row.
Excluded and opted-out custom mailers remain untouched.
Set MAIL_NOTIFICATIONS_ENABLED=false for an environment-wide off switch.
Configuration and host integration
The published configuration contains only scalars, arrays, and class strings,
so it is safe to cache with php artisan config:cache. A normal host can plug
in its models and provider integration directly in
config/mail-notifications.php; no additional service provider is required:
'notifiable_types' => [ 'account' => App\Models\Account::class, ], 'extensions' => [ 'provider_adapters' => [ Nvl\MailNotifications\Adapters\MailerSend\MailerSendAdapter::class, ], 'message_id_resolvers' => [], 'notifiable_type_providers' => [], 'scheduled_message_factories' => [ App\Mail\Scheduled\ReceiptMessageFactory::class, ], 'webhook_managers' => [ Nvl\MailNotifications\Adapters\MailerSend\MailerSendRemoteWebhookManager::class, ], ],
One provider adapter implements ProviderAdapter and may also implement
ProviderMessageIdResolver, WebhookSignatureVerifier, and
WebhookEventNormalizer. This keeps message acceptance and webhook handling
in one provider-owned class without making the core package depend on its SDK.
RemoteWebhookManager is a separate, operator-only capability registered
through extensions.webhook_managers; it is never resolved for normal mail or
webhook delivery. Standalone resolvers and notifiable-type providers remain
available for integrations that are already split across classes.
The replaceable services are also class-string configuration:
'services' => [ 'tracking_lifecycle' => Nvl\MailNotifications\Services\DatabaseTrackingLifecycle::class, 'sensitive_data_redactor' => Nvl\MailNotifications\Services\DefaultSensitiveDataRedactor::class, 'sensitive_storage_transformer' => null, ],
Custom implementations must satisfy their corresponding
TrackingLifecycle, SensitiveDataRedactor, or SensitiveDataTransformer
contract. Container injection continues to work for configured implementations.
The package validates extension and service classes during registration instead
of silently ignoring a broken integration.
The principal operational settings are:
| Concern | Configuration |
|---|---|
| Runtime | enabled, tracking.enabled, webhooks.enabled |
| Transport opt-out | tracking.excluded_mailers |
| Provider identity | providers.default, providers.mailers |
| Provider plug-ins | extensions.* |
| Host model aliases | notifiable_types |
| Persistence | storage.connection, storage.tables.* |
| Scheduled delivery | scheduling.*, disabled by default |
| Database retention | retention.*; scheduled-message pruning is disabled by default |
| Migration ownership | migrations.enabled |
| Privacy | privacy.*, retention.anonymization.*, services.sensitive_data_redactor, services.sensitive_storage_transformer |
| Safe test recipients | testing.*, with existing mail.testing taking precedence |
| Mail presentation | presentation.enabled, presentation.auto_load, brand values, and tokens |
Environment variables are available for global/tracking/presentation/webhook
switches, excluded mailers, testing interception, provider fallback, database
connection, table names, webhook payload size, redacted keys, and brand values.
Retention ages, status allowlists, scheduled-message opt-in, batch size, and
per-data-set limit also have MAIL_NOTIFICATIONS_RETENTION_*,
MAIL_NOTIFICATIONS_SCHEDULED_RETENTION_*, and
MAIL_NOTIFICATIONS_PRUNE_* environment variables.
Anonymization and protected sensitive-array storage use
MAIL_NOTIFICATIONS_ANONYMIZATION_*,
MAIL_NOTIFICATIONS_SCHEDULED_ANONYMIZATION_*, and
MAIL_NOTIFICATIONS_SENSITIVE_STORAGE_*.
MAIL_NOTIFICATIONS_METADATA_MAX_DEPTH,
MAIL_NOTIFICATIONS_METADATA_MAX_ITEMS,
MAIL_NOTIFICATIONS_METADATA_MAX_STRING_BYTES, and
MAIL_NOTIFICATIONS_METADATA_MAX_TOTAL_BYTES bound metadata processing.
Migration ownership, structured mappings, and class lists stay in the
published PHP configuration.
Optional scheduled delivery
Scheduled delivery is alias- and version-based so durable rows never contain arbitrary PHP class names. It is disabled by default. Enable it explicitly and register each host-owned factory through configuration:
MAIL_NOTIFICATIONS_SCHEDULING_ENABLED=true
use App\Mail\Scheduled\ReceiptMessageFactory; 'extensions' => [ // ... 'scheduled_message_factories' => [ ReceiptMessageFactory::class, ], ],
A factory implements ScheduledMessageFactory: alias() returns its stable
persisted name, supportsVersion() declares compatible payload versions,
validate() checks a payload before persistence and again before delivery,
and make() rebuilds the Mailable from ScheduledMessageData. The factory
owns message content, provider-specific deferred-delivery configuration, and
may select any configured Laravel mailer:
public function make(ScheduledMessageData $message): Mailable { return (new ReceiptMail($message->payload['receipt_id'])) ->mailer('mailersend'); }
Scheduling and tracking remain independently optional. When a scheduled
message must also be tracked, the factory must return an opted-in
TrackableMessage Mailable and compose the factory input into its
TrackingContext: use the stable $message->notifiable reference, merge safe
$message->metadata, and add a scheduled_message_id plus the intended
scheduled_for instant for the host read projection. Apply a payload-owned
locale with Laravel's native locale() method. The processor does not infer a
host model, locale, category, or provider template policy.
Factories must not choose TO, CC, or BCC recipients. Immediately before send,
the processor clears factory recipient state and replaces the final Symfony
message envelope with the normalized recipients persisted on the scheduled
row. This prevents normal Mailable Envelope declarations, fluent recipient
calls, callbacks registered by make() or build(), and Envelope::using
callbacks from changing the persisted recipient envelope. Enforcement is
scoped to this delivery and keeps the selected host mailer.
Laravel applies its global alwaysTo / mail.to interception afterward, so a
host test-inbox safety override remains authoritative and removes CC/BCC.
Schedule through the package write boundary using normalized package recipients:
use Carbon\CarbonImmutable; use Nvl\MailNotifications\Services\ScheduledMailScheduler; use Nvl\MailNotifications\ValueObjects\Recipient; use Nvl\MailNotifications\ValueObjects\ScheduleMailData; use Nvl\MailNotifications\ValueObjects\ScheduledRecipients; $scheduled = $scheduler->schedule(new ScheduleMailData( factoryAlias: 'receipt', payloadVersion: 1, payload: ['receipt_id' => $receipt->getKey()], recipients: new ScheduledRecipients( to: [new Recipient($account->email, $account->name)], ), scheduledFor: CarbonImmutable::parse('2026-08-01 09:00:00', 'UTC'), availableAt: CarbonImmutable::parse('2026-07-29 09:00:00', 'UTC'), )); $scheduler->reschedule( $scheduled->id, scheduledFor: $newDeliveryUtc, availableAt: $newSubmissionUtc, ); $scheduler->replacePending($scheduled->id, $replacement); $scheduler->cancel($scheduled->id);
scheduledFor is the intended recipient delivery instant. Optional
availableAt is the earlier instant at which the package may claim the row and
submit it to the host factory. Both are normalized to UTC, and initial
availability must be at or before intended delivery. Omitting availableAt
defaults it to scheduledFor, preserving ordinary due-time delivery.
An early availableAt does not make Laravel or a provider defer delivery. The
host factory must map ScheduledMessageData::$scheduledFor to its provider's
actual sendAt or equivalent option and validate that provider's supported
lead-time window. Only request early availability when that factory guarantees
deferred provider delivery; the provider-neutral package neither assumes a
three-day limit nor adds provider scheduling metadata. The factory also
receives availableAt, which represents eligibility for the current attempt;
retry backoff may move that operational value beyond the original delivery
instant, so provider delivery decisions must continue to use scheduledFor.
Cancellation and rescheduling are allowed only while a message is pending.
Rescheduling accepts the same optional availableAt; omitting it resets
availability to the new scheduledFor. replacePending() takes both values
from its replacement ScheduleMailData, so delivery and initial submission
timing are replaced atomically.
replacePending() atomically revalidates and replaces the factory alias,
payload version/data, recipient envelope, notifiable reference, metadata,
schedule, and max-attempt ceiling on a pending row. It preserves attempts
already made and rejects a ceiling that would leave the row unclaimable.
Processing and terminal rows can never be replaced.
Due messages are atomically claimed with an opaque token; the attempt counter
increments exactly once per successful claim. Delivery runs after the claim
transaction commits, and sent/retry/failure finalization must still match that
token, so an expired worker cannot overwrite a newer claim. Retry availability
uses the configured deterministic scheduling.backoff_seconds. Exhausted
messages become failed, expired claims can be recovered, and persisted failure
details contain only a bounded exception class or safe package code—never a raw
exception message. Metadata uses the package redactor.
Scheduled payloads are unredacted and must contain only minimal durable
identifiers; never put rendered content, secrets, tokens, or provider responses
in them. Payload and metadata must each be a top-level object with string keys;
top-level JSON lists are rejected before either create or replacement writes.
Scheduled metadata is redacted by the package.
Payload bytes and the deduplicated TO+CC+BCC recipient count are bounded before
host factory validation and again before delivery. Configure the limits with
scheduling.max_payload_bytes and scheduling.max_recipients.
Claim fencing prevents stale database writes, not duplicate external side effects. A worker crash after provider acceptance but before local sent finalization leaves an ambiguous claim that recovery may deliver again. Use a provider idempotency key when the transport supports one, and configure the claim TTL longer than one worst-case send attempt plus operational margin. The processor claims each message immediately before its send attempt, so later messages in a bounded run do not consume their lease while earlier messages are being delivered.
The package supplies bounded one-shot commands but does not choose the host's scheduler frequency:
php artisan nvl:mail-notifications:process-scheduled --limit=50 php artisan nvl:mail-notifications:recover-scheduled --limit=50
Run both commands from the host scheduler at a cadence appropriate to the application and claim TTL. A safe default is:
use Illuminate\Support\Facades\Schedule; Schedule::command('nvl:mail-notifications:process-scheduled') ->everyMinute() ->onOneServer() ->withoutOverlapping(); Schedule::command('nvl:mail-notifications:recover-scheduled') ->everyMinute() ->onOneServer() ->withoutOverlapping();
Install these entries only while mail-notifications.scheduling.enabled=true.
The package never registers them or chooses their cadence, and scheduling
readiness is skipped while the feature is disabled. While enabled, the strict
Doctor requires at least one registered scheduled-message factory and both
host scheduler entries. Configure Laravel's
scheduler mutex cache to use one shared lock store across every application
node so both guards coordinate globally. ScheduledMailClaimed, ScheduledMailSent,
ScheduledMailRetrying, ScheduledMailFailed, and ScheduledMailRecovered
cover processing. ScheduledMailScheduled, ScheduledMailCancelled,
ScheduledMailRescheduled, and ScheduledMailReplaced cover host mutations.
Scheduling and replacement events expose both UTC timing values; rescheduling
and replacement also expose both previous values.
All are storage-transaction after-commit observational events. The package
dispatcher attaches each event to the exact configured storage connection,
rather than Laravel's generic cross-connection after-commit marker, so an
unrelated open host transaction does not delay a committed package event and a
storage rollback emits nothing. Regular listeners therefore run after the
package write commits. A host listener that separately opts into Laravel's
after-commit handling retains that host-owned behavior. Listener failures are
reported without altering delivery state.
Explicit database retention
Retention is a bounded, database-only operation. It never calls a mail provider, sends a message, or registers itself with Laravel's scheduler. The host must invoke or schedule each run explicitly:
php artisan nvl:mail-notifications:prune --dry-run
php artisan nvl:mail-notifications:prune --limit=500
php artisan nvl:mail-notifications:prune \
--before=2026-06-30T00:00:00Z \
--limit=500
--dry-run returns deterministic candidate and owned provider-event counts
without mutation. --before accepts an absolute, non-future RFC 3339 timestamp
and normalizes it to UTC. --limit is applied independently to tracked
notifications and scheduled messages, and deletion queries use the configured
bounded batch size.
Tracked notifications are eligible only when their status is in
retention.notifications.statuses and their lifecycle completion
status_changed_at is older than the cutoff. Legacy rows with no
status_changed_at fall back to created_at. Provider-event rows owned by a
selected notification are deleted in the same transaction. The package schema
indexes both (status, status_changed_at) and the existing
(status, created_at) legacy fallback path; the doctor reports either missing
lookup.
Scheduled-message retention is separately opt-in:
MAIL_NOTIFICATIONS_SCHEDULED_RETENTION_ENABLED=true
When disabled, pruning and the retention doctor check do not inspect the
scheduled-message table or validate its retention age/status settings. When
enabled, only allowlisted sent, failed, and cancelled rows can be removed.
Age is measured from the matching sent_at, failed_at, or cancelled_at;
legacy rows missing that timestamp fall back to updated_at. Pending and
processing rows are always protected, regardless of age or configuration.
Fresh scheduled-mail storage includes a status-prefixed index for each terminal
timestamp and for the updated_at fallback.
Run the strict doctor after changing retention configuration. Its independent
retention.configuration check validates notification settings, batch/limit
bounds, and—only when enabled—scheduled-message settings.
Protected storage and history anonymization
Sensitive-array storage is an opt-in persistence hook, disabled by default. Set a cache-safe transformer class and enable it only after the strict doctor passes:
use Nvl\MailNotifications\Services\LaravelEncrypterSensitiveDataTransformer; 'services' => [ // ... 'sensitive_storage_transformer' => LaravelEncrypterSensitiveDataTransformer::class, ], 'privacy' => [ // ... 'sensitive_storage' => [ 'enabled' => true, 'max_transformed_bytes' => 262_144, ], ],
The built-in transformer uses Laravel's configured encrypter. New values use
the current application key; Laravel's APP_PREVIOUS_KEYS support keeps older
ciphertext readable during rotation. A host may instead provide any
SensitiveDataTransformer class with its own current and previous protection
profiles.
Protection covers notification TO/CC/BCC recipients and metadata, provider event metadata, and scheduled-message payload, TO/CC/BCC recipients, and metadata. Subjects, sender fields, primary-recipient lookup, notifiable references, status and provider identities remain ordinary queryable columns. Use database or disk encryption as the broader at-rest control when those scalar columns must also be protected.
Protected arrays use a versioned package envelope. Version 2 base64-encodes opaque transformer output, so arbitrary binary ciphertext remains valid JSON; version 1 envelopes and legacy plaintext JSON remain readable. Enabling protection therefore does not require an immediate backfill. New and deliberately rewritten values are protected through package lifecycle and scheduler services, whose internal model casts apply the storage codec. Treat package Eloquent models as host query projections, not arbitrary runtime write APIs. Direct model or query-builder writes to lifecycle, payload, recipient, or metadata fields bypass package invariants and are unsupported. Protected arrays are opaque to database JSON queries.
Disabling protection does not reinterpret existing envelopes as plaintext.
Missing or retired keys, a wrong transformer, a malformed envelope, and
disabled protection with protected history all throw
UnreadableSensitiveDataException. Keep the matching transformer and previous
keys available for at least as long as protected rows are retained.
Anonymization is a separate, disabled-by-default stage that removes identifying content without deleting lifecycle history:
MAIL_NOTIFICATIONS_ANONYMIZATION_ENABLED=true MAIL_NOTIFICATIONS_ANONYMIZATION_DAYS=180 MAIL_NOTIFICATIONS_SCHEDULED_ANONYMIZATION_ENABLED=true MAIL_NOTIFICATIONS_SCHEDULED_ANONYMIZATION_DAYS=90
php artisan nvl:mail-notifications:anonymize --dry-run
php artisan nvl:mail-notifications:anonymize --limit=500
php artisan nvl:mail-notifications:anonymize \
--before=2026-06-30T00:00:00Z \
--limit=500
Notification anonymization clears subject, sender, effective recipients, primary-recipient lookup, host notifiable association, and metadata. Provider event anonymization clears only event metadata. Scheduled-message anonymization clears payload, recipients, failure detail, host notifiable association, and metadata from allowlisted terminal rows. Row, correlation, queue, provider, provider-message, and provider-event identities remain available for idempotency, late webhook correlation, and operational audit.
Each data set has an independent configured or command limit and is updated in
bounded batches. A nullable redacted_at marker makes repeated runs honest and
idempotent; provider events are independently marked so a notification with
many events may require more than one run. Scheduled-message anonymization has
its own opt-in switch. Run anonymization before deletion when the host needs a
minimum-data retention window. Pruning remains a separate command and does not
implicitly require or schedule anonymization.
An anonymized notification stays anonymized during later acceptance, local
failure reconciliation, and provider retries. A new provider event correlated
after its notification was anonymized is persisted with no metadata and its own
redacted_at marker; an exact retry remains idempotent, while immutable event
identity conflicts still fail closed. These paths never rehydrate cleared
metadata or move the original redaction marker.
Tokenized Markdown presentation
The package supplies responsive HTML and plain-text components plus a polished
neutral default theme. It contains no host-specific assets, social links,
translation keys, or brand copy. It respects mail.markdown.theme,
mail.markdown.paths, the configured application name and URL, and every
Laravel mailer/from setting. It does not select or reconfigure a transport.
Configure brand values and safe design tokens under
mail-notifications.presentation. Tokens cover typography, canvas and surface
colors, heading/body/muted colors, primary and semantic colors, borders,
container and component radii, content and logo sizing, and text sizes. The
values are validated before they are shared with the Blade theme. Use
MAIL_BRAND_HEADER_ENABLED=false or MAIL_BRAND_FOOTER_ENABLED=false when an
application wants body-only messages, and configure the remaining
MAIL_BRAND_* values without changing package views.
Set MAIL_NOTIFICATIONS_PRESENTATION_ENABLED=false to avoid automatically
loading package components, or
MAIL_NOTIFICATIONS_PRESENTATION_AUTO_LOAD=false when presentation should be
available only after publishing. Once views are published into
resources/views/vendor/mail, they are application-owned Laravel overrides.
Testing interception
The package honors an existing mail.testing configuration first. When it is
absent, mail-notifications.testing provides the same environment-aware
settings. Enabling test mode applies Laravel's global recipient override, so
all outbound mail is redirected before tracking captures effective recipients.
The package never bypasses Laravel's configured mailer, queue, or from address.
MAIL_TESTING_ENABLED=true MAIL_TESTING_TO_ADDRESS=mail-preview@example.test MAIL_TESTING_TO_NAME="Mail Preview" MAIL_TESTING_RESPECT_ENV=true MAIL_TESTING_ENVIRONMENTS=local,testing,staging
Keep environment enforcement enabled and exclude production. The strict doctor reports a missing/invalid recipient, an empty allowlist, unrestricted interception, or a production allowlist before queue workers are deployed.
Tracking context
TrackingContext contains a stable message category, optional host notifiable
reference, and safe metadata. A host model may implement MailTrackable and be
attached without persisting a PHP class name. Its
mailNotificationType() alias must be registered in notifiable_types or by a
ProvidesNotifiableTypes extension before the context can be persisted:
$context = TrackingContext::forCategory('account.password-reset') ->forNotifiable($account) ->withMetadata(['request_id' => $requestId]) ->withCorrelation(['reset_record_id' => $resetRecordId]);
Use withCorrelation() only for identifiers that an in-process
MailTrackingStarted listener needs without reloading the notification row.
The validated map is delivered directly on the event and is also persisted
under the redacted metadata.correlation key. It contains at most 20
lowercase snake-case keys of at most 64 characters. Values are only UTF-8
strings up to 255 characters, integers, booleans, or null. Keys containing
email, token, secret, password, or payload are rejected, as are
nested values, objects, resources, floats, and invalid UTF-8. Correlation is
preserved through forNotifiable() and withMetadata() in either order.
General metadata is never copied onto the event.
The configured redactor recursively masks sensitive keys before persistence,
redacts object/resource values by default, and bounds nesting, item count,
individual string bytes, and aggregate bytes so cyclic or excessively broad
input cannot exhaust a worker. Key matching lowercases and removes separators,
so defaults such as api_key, two_factor_code, verification_code,
magic_link, and otp also cover camelCase and kebab-case variants. Rendered
HTML, rendered text, template variables, provider responses, and raw webhook
payloads are never stored by core. BCC recipients remain in storage for
operational correlation and must be protected by host authorization.
Provider integration
Core has no provider SDK dependency. Register adapters and resolvers through
the configuration-first extension lists above. Advanced integrations may
instead tag container bindings with ProviderAdapter::CONTAINER_TAG,
ProviderMessageIdResolver::TAG, or ProvidesNotifiableTypes::TAG; configured
and tagged extensions are composed into the same registries.
The built-in
Nvl\MailNotifications\Adapters\MailerSend\MailerSendAdapter is optional and
unregistered by default. It has no MailerSend SDK or API dependency. Opt in
through extensions.provider_adapters and configure its provider-neutral
boundary:
use Nvl\MailNotifications\Adapters\MailerSend\MailerSendAdapter; use Nvl\MailNotifications\Adapters\MailerSend\MailerSendRemoteWebhookManager; 'extensions' => [ 'provider_adapters' => [MailerSendAdapter::class], 'webhook_managers' => [MailerSendRemoteWebhookManager::class], ], 'providers' => [ 'mailersend' => [ 'mailers' => ['mailersend'], 'signing_secret' => env( 'MAIL_NOTIFICATIONS_MAILERSEND_SIGNING_SECRET', ), // The package includes MailerSend's fixed webhook.test secret. 'signature_headers' => ['signature'], 'message_id_headers' => [ 'x-mailersend-message-id', 'x-message-id', ], 'timestamp_bounds' => [ 'maximum_past_age_seconds' => 604800, 'maximum_future_skew_seconds' => 300, ], 'management' => [ 'enabled' => env( 'MAIL_NOTIFICATIONS_MAILERSEND_MANAGEMENT_ENABLED', false, ), 'token' => env('MAIL_NOTIFICATIONS_MAILERSEND_API_TOKEN'), 'domain_id' => env('MAIL_NOTIFICATIONS_MAILERSEND_DOMAIN_ID'), 'api_url' => 'https://api.mailersend.com/v1', 'timeout_seconds' => 10, 'connect_timeout_seconds' => 3, 'pagination' => [ 'page_size' => 100, 'max_pages' => 10, ], 'webhook' => [ 'name' => 'Mail Notifications', 'url' => env( 'MAIL_NOTIFICATIONS_MAILERSEND_WEBHOOK_URL', ), 'events' => [ 'activity.sent', 'activity.delivered', 'activity.deferred', 'activity.opened', 'activity.opened_unique', 'activity.clicked', 'activity.clicked_unique', 'activity.soft_bounced', 'activity.hard_bounced', 'activity.unsubscribed', 'activity.spam_complaint', ], 'enabled' => true, 'version' => 2, ], ], ], ],
MailerSend v2 signs the exact raw request body using lowercase hexadecimal
HMAC-SHA256 in its Signature header; its signature algorithm has no signed
timestamp or signature-tolerance input. Independently, the adapter requires the
verified provider created_at to be no more than seven days old or five minutes
in the future by default. Both event-time bounds are validated by the strict
doctor. The transport-specific X-MailerSend-Message-Id header is accepted
directly. The generic X-Message-Id compatibility header is trusted only for
Laravel mailer names listed in providers.mailersend.mailers.
MailerSend signs the URL-validation webhook.test request with its documented
fixed test secret. The adapter accepts that secret only when the authenticated
root event type is exactly webhook.test; it can never authenticate an
activity event. Validation requests return a typed acknowledgement without
creating a delivery event.
The adapter normalizes sent, delivered, deferred, opened,
opened_unique, clicked, clicked_unique, soft_bounced, hard_bounced,
spam_complaint, and unsubscribed, and extracts MailerSend message identity
from webhook and transport boundaries. The authenticated webhook route remains
host-owned.
The host owns the webhook route and controller. Build the package request from the original Laravel request after selecting the route's provider:
use DomainException; use Illuminate\Http\Request; use Illuminate\Http\Response; use Nvl\MailNotifications\Exceptions\UnmatchedDeliveryEventException; use Nvl\MailNotifications\Services\WebhookProcessor; use Nvl\MailNotifications\ValueObjects\WebhookRequest; use Throwable; public function __invoke( Request $request, WebhookProcessor $webhooks, ): Response { try { $webhooks->process( provider: 'mailersend', request: WebhookRequest::fromLaravelRequest( provider: 'mailersend', request: $request, ), ); } catch (UnmatchedDeliveryEventException) { return response()->noContent(503); } catch (DomainException) { return response()->noContent(400); } catch (Throwable $exception) { report($exception); return response()->noContent(503); } return response()->noContent(); }
The factory preserves the raw body, HTTP method, and request target (including
the query string), and supplies a case-insensitive header map for provider
signature algorithms. Real HTTP requests must be POST with an allowlisted JSON
content type; application/json parameters such as charset=utf-8 are
accepted. Return 204 only after the synchronous processor has durably applied
or explicitly acknowledged the event. The example returns 503 for a recent
tracking-row visibility race or an operational/storage failure so the provider
can retry, and 400 for a verified but invalid request or identity conflict.
Never expose exception messages in the response. If a host adds an asynchronous
boundary, it must durably persist the verified work before returning 2xx.
The processor deliberately uses explicit typed stages—registered adapter, signature verifier, normalizer, lifecycle—not Laravel's generic pipeline. The stages have different contracts and failure semantics, so adapters and replaceable lifecycle services are the supported extension points.
Remote MailerSend management is disabled and unregistered by default. It uses
Laravel's HTTP client against the official /v1/webhooks endpoints and never
calls MailerSend during package registration or boot. Before syncing, deploy
the host-owned HTTPS route above at the exact configured URL and make sure it
can return 2xx for MailerSend's signed validation probe. Then configure the
manager class, API token, sending-domain ID, unique managed name, HTTPS callback
URL, bounded timeout/pagination, supported activity events, and v2 payload:
php artisan nvl:mail-notifications:webhooks:sync --provider=mailersend --dry-run php artisan nvl:mail-notifications:webhooks:sync --provider=mailersend
An existing same-name webhook must match exactly; inspect the dry run and add
--force to update drift or upgrade a legacy v1 record. More than one
same-name record is ambiguous and fails closed. Removal is scoped to the unique
configured name unless the operator explicitly requests every webhook in the
configured domain:
php artisan nvl:mail-notifications:webhooks:remove --provider=mailersend --dry-run php artisan nvl:mail-notifications:webhooks:remove --provider=mailersend php artisan nvl:mail-notifications:webhooks:remove --provider=mailersend --all --dry-run
MailerSend generates an individual signing secret when the webhook is created.
The commands deliberately never print or persist that secret—or any provider
response body. Retrieve the generated secret securely from MailerSend, copy it
to MAIL_NOTIFICATIONS_MAILERSEND_SIGNING_SECRET, reload cached configuration,
and run php artisan nvl:mail-notifications:doctor --strict. The fixed
webhook.test secret is only for URL validation and must never replace the
individual activity signing secret. Activity signing secrets must contain
between 16 and 4,096 bytes; the URL-validation secret is MailerSend's exact
fixed value and is intentionally not environment-configurable.
WebhookProcessor enforces the package and webhook switches, bounds the raw
payload before adapter decoding, requires signature verification and
normalization, checks provider identity at every boundary, and applies the
event through the configured lifecycle. Authenticated unsupported events are
acknowledged without lifecycle mutation by default so provider event expansion
does not create retries; set
MAIL_NOTIFICATIONS_WEBHOOK_UNKNOWN_EVENT_POLICY=reject for strict rejection.
Acknowledgements dispatch MailWebhookAcknowledged as a safe observational
after-commit event. Set
MAIL_NOTIFICATIONS_WEBHOOKS_ENABLED=false to stop webhook processing without
changing Laravel mail delivery. When the MailerSend adapter is registered and
webhooks are enabled, the strict package doctor validates its signing secret,
header names, and allowed Laravel mailer names before traffic arrives.
The canonical readiness namespaces are
mail-notifications.webhooks.enabled,
mail-notifications.providers.mailersend.signing_secret, and
mail-notifications.providers.mailersend.management.enabled. Never configure
these package capabilities under the predecessor mailnotifications.*
namespace.
A MailerSend sending domain may also emit valid events for untracked Mailables.
Tracked-message lookup misses use a separate
webhooks.unmatched_events.policy: the safe
retry_then_acknowledge default retries recent events for five minutes so a
tracking-row visibility race can recover, then acknowledges older misses as
unmatched_event without persistence. Set
MAIL_NOTIFICATIONS_WEBHOOK_UNMATCHED_EVENT_POLICY=reject for perpetual strict
rejection, or explicitly choose acknowledge only when immediate dropping of
tracking races is acceptable. This policy is independent from unknown event
types.
Core correlation is intentionally strict: it uses correlation_id when one is
present, otherwise the exact (provider, provider_message_id) identity. It
never falls back to recipient address, subject, category, or timing because
those values are not transport-message identities and can match unrelated
deliveries. A custom TrackingLifecycle must likewise resolve exactly one
tracked delivery before mutation and throw
AmbiguousDeliveryEventException when multiple candidates remain.
The package-owned schema makes both supported identities unique, so ambiguity
indicates a broken/adopted schema or a defective custom lifecycle. The
processor acknowledges that verified webhook once as ambiguous_event
without lifecycle mutation or retry, dispatches the privacy-safe after-commit
WebhookEventAmbiguous, and also dispatches MailWebhookAcknowledged. Alert
on the typed ambiguity event and repair the identity constraint or lifecycle;
do not add recipient fallback.
Provider events are idempotent by provider and event identifier. Status changes are monotonic, stale events do not move state backward, and terminal failures cannot be replaced by older success events. Reusing an event identifier with different immutable event data is rejected as an identity conflict instead of being treated as a harmless retry. Late delivery milestones may backfill their timestamp without weakening a more advanced status.
One tracking record represents one transport message. TO, CC, and BCC arrays capture the effective wire recipients, while provider lifecycle status remains message-level. Send a separate tracked message per recipient when the provider emits recipient-specific delivery outcomes that must remain independently queryable.
Administrative delivery reads
The package exposes ListMailNotificationsAction,
ShowMailNotificationAction, GetMailNotificationStatisticsAction, and
SuggestMailNotificationsAction. Exact identity reads are available through
ListMailNotificationsForNotifiableAction with a registered
NotifiableReference, and ShowMailNotificationByProviderMessageAction with a
registered ProviderMessageId. Bind MailNotificationReadAuthorization, or
configure a class implementing it under
mail-notifications.management.authorization.class. The built-in adapter
denies every read unless the host explicitly authorizes the distinct list,
view, statistics, or suggest ability.
MailNotificationReadQuery accepts only bounded search, status, mailer,
category, date, accepted/failed, sort, direction, and pagination values. List
results use a deterministic secondary ID order and cap page size with
management.maximum_per_page; suggestions require a non-empty bounded search
and use management.suggestion_limit.
The Actions return stable value objects rather than mutable Eloquent models. Their projections do not select or serialize TO/CC/BCC arrays, notification or provider-event metadata, raw webhook content, scheduled payloads, or scheduler claims. Hosts continue to own controllers, routes, rate limiting, permissions, translations, and UI composition.
GetMailNotificationStatisticsAction includes mailers and categories as
lists of MailNotificationAggregate { key, count } values. Each dimension uses
one grouped query over the same authorized filters, orders by count descending
then key ascending, and returns at most ten rows. Null or blank dimension values
are combined under unknown. Consumers should reuse these projections instead
of grouping MailNotification directly.
Scheduled queue administration is a separate fail-closed surface. Bind
ScheduledMailReadAuthorization, or configure its implementation and callback
under mail-notifications.management.scheduled_authorization. The
ListScheduledMailMessagesAction, ShowScheduledMailMessageAction, and
GetScheduledMailStatisticsAction authorize distinct list, view, and
statistics abilities. ScheduledMailReadQuery accepts bounded status,
factory, registered notifiable, delivery-window, due-only, sort, direction, and
pagination filters; page size is capped by scheduled_maximum_per_page.
Scheduled list, detail, page, and statistics value objects never expose the serialized payload, metadata, CC/BCC envelopes, errors, claim token, or lock state. They decrypt the TO envelope only inside the package and project a single primary recipient for display; redacted history returns no recipient.
Failure policy
fail_closed is the default. A failure to create the pending record prevents
the opted-in message from reaching transport. Set
MAIL_NOTIFICATIONS_FAILURE_POLICY=fail_open when delivery must continue even
if pre-send tracking persistence is unavailable.
The same policy applies when a custom mailer contract hides the transport:
| Policy | Custom mailer contract |
|---|---|
fail_closed |
Emits MailTrackingFailed and stops before delivery. |
fail_open |
Emits MailTrackingFailed and delivers once without tracking. |
Do not synchronously send fail-closed tracked mail inside an open host database transaction on the same storage connection. The pending write joins that transaction, so a later host rollback could erase tracking after the transport has accepted the message. Dispatch queued mail after commit or configure an independent tracking connection when the surrounding business transaction must remain open.
Laravel invokes failed() after a queued Mailable exhausts its attempts. The
concern implements that hook and synchronizes a terminal failure even when the
job failed before normal send/bootstrap. It reuses a completed attempt when one
exists; otherwise it creates one deterministic fallback row fenced by the queue
reference. It never selects a pending attempt by recipient, subject, timing, or
queue-group ordering, so one duplicate worker cannot mark another worker's
in-flight attempt failed. Repeated callbacks are idempotent through the fallback
primary/correlation identity.
The fallback snapshot contains only declarative envelope addresses, subject, configured sender, safe tracking context, and redacted metadata. It never renders or stores the message body and stores only the exception class, not its message or trace.
When a Mailable already defines an application failure hook, explicitly compose the package helper:
use Throwable; public function failed(?Throwable $exception): void { $this->recordMailTrackingFailure($exception); // Host-owned alerting or cleanup... }
recordMailTrackingFailure() accepts Laravel's nullable manual-failure value
and never lets tracking synchronization replace the original queue failure. A
custom newQueuedJob() override must delegate to its parent so the queue
reference is assigned before serialization. The callback is necessarily
best-effort when Laravel cannot deserialize the queued command, or when the
worker is terminated before failure callbacks can run.
Once a transport has accepted a message, a tracking update failure never throws
back into the mail send path because doing so could cause a queue retry and a
duplicate delivery. The package emits MailTrackingFailed with identifiers
and exception type only. When provider identity was resolved before the update
failed, the event also carries that safe ProviderMessageId. A host may enqueue
an idempotent repair that reconstructs the event's TrackingAttempt and calls
TrackingLifecycle::accepted() with that identity; the package database
lifecycle accepts the same identity repeatedly. Repair tracking state only—
never resend the Mailable. Package lifecycle events are observational:
exceptions from host listeners are reported through Laravel's exception handler
but do not cancel delivery, replace a transport exception, or force a webhook
retry.
Operations
Inspect configuration, retention and anonymization bounds, sensitive-storage round trips, production interception safety, required column types, lengths, nullability and defaults, exact database status invariants, case-sensitive provider identities, webhook readiness, both administrative authorization boundaries, scheduled-message factories, required host scheduler entries, foreign-key ownership, UTC behavior, and operational indexes without mutation:
php artisan nvl:mail-notifications:doctor --strict --format=json
The host owns mail transport configuration, queue policy, webhook routes, provider credentials, authorization decisions, scheduled-mail command frequency, retention/anonymization scheduling, encryption-key retention, published view customizations, controllers, and UI composition.
After changing environment-backed settings or configured extension classes in production, rebuild Laravel's configuration cache and restart queue workers.
When nvl/core is installed and its Data provider is loaded, the provider
registers its public backed enums as TypeScript sources. Strict generation can
therefore resolve contracts such as
Nvl.MailNotifications.Enums.MailDeliveryStatus without a host
LiteralTypeScriptType replacement:
php artisan nvl:data:types:generate --fail-on-warning php artisan nvl:data:types:check --fail-on-warning
Package services and model-managed timestamps are generated, stored, compared, and restored in UTC with microsecond precision, independently from the host application timezone. The configured database/session timezone must still be UTC so database-generated values and session comparisons share that contract across SQLite, PostgreSQL, and MySQL-family deployments.
Development and verification
Package-owned Eloquent factories are available to consumer test suites without
host factory discovery. Their defaults use reserved example.test addresses,
omit subjects and host notifiable references, and preserve package lifecycle
invariants:
use Nvl\MailNotifications\Models\MailNotification; use Nvl\MailNotifications\Models\MailNotificationEvent; use Nvl\MailNotifications\Models\ScheduledMailMessage; $notification = MailNotification::factory()->delivered()->create(); $event = MailNotificationEvent::factory() ->for($notification) ->bounced() ->create(); $scheduledMessage = ScheduledMailMessage::factory()->processing()->create();
Notification and provider-event factories expose a named state for every
MailDeliveryStatus. Scheduled-message factories expose pending,
processing, sent, failed, and cancelled states plus due and
retrying operational fixtures.
composer validate --strict
composer format:test
composer analyse
composer test
The isolated Testbench suite verifies package boot, opt-in and opt-out behavior,
global and per-mailer switches, effective recipients, queue serialization,
failure policy, transition monotonicity, event idempotency, redaction, and
forbidden dependency boundaries. It also verifies database-enforced enum
allowlists, protected-array legacy reads and rotation failures, independently
bounded anonymization, consolidated creator schemas, and forward-only rollback.
Scheduled-mail
coverage includes registry versioning, normalized recipients, token-fenced
transitions, mailer selection, retry exhaustion, claim recovery, commands, and
observational events. On
PostgreSQL, a dedicated committed-fixture test synchronizes two worker
processes at the SELECT ... FOR UPDATE boundary and proves that every due row
is claimed once with a unique fence. It then recovers and reclaims one lease to
prove that stale-token finalization cannot overwrite the newer claim. A second
two-worker proof synchronizes duplicate queued-failure callbacks immediately
before fallback insertion and asserts one failed row plus exactly one started,
failed, and status-change event. These tests run migrate:fresh only when the
PostgreSQL database name starts with nvl_mail_notifications_test_; SQLite and
environments without the optional process primitives skip only these
database-specific proofs. The suite also boots a complete configuration-first
host fixture with custom services, notifiable aliases, two message-ID resolver
styles, authenticated webhook normalization, and payload/runtime guards.
License
Tenant delivery lifecycle
Scheduled records and tracking rows persist canonical ownership; provider events inherit their notification owner. Workers claim/finalize only inside one tenant context, factories resolve by class after restoration, and verified webhooks recover context only from stored provider identity. Tenant Settings may select an approved named mailer, while credentials remain deployment-owned.
NVL Mail Notifications is open-sourced software licensed under the MIT license.