---
title: "Email — Notifications for Laravel | Roundly"
description: "Leading and trailing de-dup within the window, the life of a delivery, at-most-once sending, rendering, minimal mail and the address resolver."
url: https://roundly-consulting.com/open-source/docs/notifications-for-laravel/email
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Notifications for Laravel](https://roundly-consulting.com/open-source/docs/notifications-for-laravel.md)

# Email

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”.

```php
// 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

| Status | Meaning |
| --- | --- |
| `pending → sending → sent` | The normal path. DeliverMailJob claims the row by compare-and-set, so a retried job or a second scheduler sends nothing. |
| `held` | Quiet hours — released by the sweep when they end; checked again at delivery and held again if quiet hours started meanwhile. |
| `digest` | Parked for the user’s digest. |
| `cancelled` | recipient\_gone, unknown\_type, subject\_gone, no\_access, muted (mandatory excepted), revoked — re-checked right before sending. |
| `failed` | render (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). |
| `undeliverable` | no\_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:

```php
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:

```php
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:

```php
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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/notifications-for-laravel.md)
