NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Mail goes through Laravel’s mail notifications (TypedMailNotification), in the recipient’s locale (HasLocalePreference::preferredLocale()).

De-dup within the window

The de-dup key has the same parts as the inbox collapse key, so one inbox item and one delivery always pair up:

  • Leading (the default, notifications.mail.strategy) — the first event mails now (or at the end of quiet hours); later events in the window fold into that delivery and send nothing more. Ten events in five minutes → one mail.
  • Trailing (mailStrategy(): MailStrategy::Trailing) — the delivery waits first event + window; every event folds in, and one mail goes out with the final count and the latest content.
  • Urgent types always mail on the leading edge. A mail not yet sent absorbs later events (Folded); a sent one only counts them (Deduplicated). When several events folded before it went out, it says “And N more updates”.
// Leading (default): the first event mails now; later ones in the window fold into it
Notifications::send('request.awaiting_approval', $request, $approver);   // MailOutcome::Pending
Notifications::send('request.awaiting_approval', $request, $approver);   // MailOutcome::Folded (or Deduplicated once sent)

// Trailing: one mail after the window, with the final count and the latest content
final class CommentPosted extends NotificationType
{
    public function window(): ?string { return 'PT10M'; }
    public function mailStrategy(): MailStrategy { return MailStrategy::Trailing; }
    // …
}

The life of a delivery

StatusMeaning
pending → sending → sentThe normal path. DeliverMailJob claims the row by compare-and-set, so a retried job or a second scheduler sends nothing.
heldQuiet hours — released by the sweep when they end; checked again at delivery and held again if quiet hours started meanwhile.
digestParked for the user’s digest.
cancelledrecipient_gone, unknown_type, subject_gone, no_access, muted (mandatory excepted), revoked — re-checked right before sending.
failedrender (a type hook threw — reported, no retry), transport (after mail.max_attempts with mail.backoff between tries), unknown (still sending after mail.lease — never re-sent).
undeliverableno_address — the resolver returned null, or the recipient has no mail route.

The address is resolved before Laravel’s mail channel: the configured address resolver, else routeNotificationFor('mail'). With notifications.queue.enabled off the job runs inline after the commit; otherwise it is dispatched on notifications.queue.connection / queue. Events: NotificationMailSent, NotificationMailFailed, NotificationMailHeld.

Rendering

Subject = title(); a greeting (with the recipient’s name when present); the first line = body() (else the title); “And N more updates” when folded; the action button (url() + actionText(), absolute http(s) only); a footer — the mandatory notice for mandatory types, else the preferences link when notifications.mail.preferences_url (a URL or a route name) is set; then the type’s mail($message, $payload) hook. Text goes through Laravel’s markdown template, which escapes it. Assert it in a host test:

use Illuminate\Support\Facades\Notification;
use RoundlyConsulting\Notifications\Mail\TypedMailNotification;

Notification::assertSentTo($user, TypedMailNotification::class, fn ($n) => $n->type === 'security.alert'
    && $n->message->subject === 'Security alert');

Minimal mail for sensitive subjects

When a type’s sensitive($subject) is true and notifications.mail.minimal_for_sensitive is on (the default), the mail carries minimalTitle() (“New notification from :app”), one line and the button — body() and mail() are not called. Digest lines of sensitive items use the minimal line too. The inbox is unaffected: it is behind your auth.

The address resolver

notifications.mail.address_resolver names a ResolvesMailAddress that decides the address of every mail the package sends — typed mail and digests alike; the payload is null for a digest. A string sends to that address; null makes the delivery undeliverable (a digest run fails as no_address). With contacts-for-laravel, mail goes to the user’s primary email contact:

class User extends Authenticatable
{
    use HasContacts;
    use Notifiable;
    use ReceivesNotifications;
    use RoutesNotificationsViaContacts;   // mail goes to the primary email contact
}

For verified addresses only, bind a resolver — a user without a verified primary email then gets undeliverable, and no digest either:

use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Contacts\Enums\ContactType;
use RoundlyConsulting\Notifications\Contracts\ResolvesMailAddress;
use RoundlyConsulting\Notifications\NotificationPayload;

final class VerifiedEmailResolver implements ResolvesMailAddress
{
    public function address(Model $recipient, ?NotificationPayload $payload): ?string   // null payload = a digest
    {
        $contact = $recipient->primaryContact(ContactType::Email);

        return $contact?->verified_at !== null ? $contact->value : null;
    }
}

// config/notifications.php
'mail' => ['address_resolver' => App\Notifications\VerifiedEmailResolver::class],

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.