Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
laravel-waitlist-mail
taldres/laravel-waitlist
claude-code
waitlist
mail
queued-listeners
double-opt-in
unsubscribe
reconfirmation

Send the mails of a taldres/laravel-waitlist integration: the double opt-in confirmation, the preference page link, a welcome mail, launch mails and newsletters to the people who agreed, with the right unsubscribe links, one-click headers and a sender per project.

Install
php artisan boost:add-skill taldres/laravel-waitlist

Save this content to: .claude/skills/laravel-waitlist-mail/SKILL.md

# Laravel Waitlist: mail

Use this skill when an application using `taldres/laravel-waitlist` needs to send
any mail to people on a waitlist, or set up a mail provider for it.

## Primary Goal

- every mail the flow needs goes out from a queued, encrypted listener, carries
  the link that matches its purpose, and reaches only addresses with consent in force

## Workflow

### 1. Know which mail is due when

The package sends no mail and calls no provider. It fires events; the app's
listeners send through any Laravel mailer (Resend, Postmark, SES, SMTP, …).

| Mail | Due on | Must carry |
| --- | --- | --- |
| Confirmation (double opt-in) | `EntrySubscribed` with `$event->requiresConfirmation` | `$event->confirmUrl`, `$event->unsubscribeUrl` |
| Preference page link | `ManageLinkRequested` | `$event->manageUrl`, sent only to `$event->entry->email` |
| Welcome (optional) | `EntryConfirmed` | `$event->unsubscribeUrl`, `Waitlist::listUnsubscribeHeaders($event->entry)` |
| Still interested? (with `retention.reconfirm_after_days`) | `ReconfirmationRequested`, fired by `waitlist:prune` once per period | `$event->confirmUrl` (posted back like a confirmation), `$event->unsubscribeUrl`, `$event->answerBy`; without an answer the address lapses |
| Launch or update about one list | the app decides | per-recipient link that leaves the list |
| Newsletter (optional purpose) | the app decides | per-recipient link that withdraws only the newsletter |

### 2. Confirmation listener

`php artisan waitlist:install --mail` writes the listener below, its
`ConfirmWaitlistMail` and `resources/views/mail/waitlist/confirm.blade.php` into
the app, skipping files that exist. Laravel discovers the listener; do not
register it again (no listener is written while `EntrySubscribed` already has one:
the command replaces a confirmation listener, it does not add to one). With
`event:cache`, re-cache; with event discovery off, register it yourself. The files
are the app's to edit afterwards.

```php
namespace App\Listeners;

use App\Mail\ConfirmWaitlistMail;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;
use LogicException;
use Taldres\Waitlist\Events\EntrySubscribed;
use Taldres\Waitlist\Facades\Waitlist;
use Throwable;

class SendWaitlistConfirmationMail implements ShouldBeEncrypted, ShouldQueue
{
    public function handle(EntrySubscribed $event): void
    {
        if (! $event->requiresConfirmation) {
            return;
        }

        // Erased since the event: a retried job must not mail the address again.
        if ($event->entry->fresh() === null) {
            return;
        }

// Report only a send that did not happen. After the mail is out, a
        // failure must not free the cooldown, so it is not reported. A
        // MessageSent listener that throws counts as a failed send.
        try {
            // Null only while the project sets no confirm page and the package
            // routes are off: fail loudly rather than mail a confirmation without a link.
            $confirmUrl = $event->confirmUrl ?? throw new LogicException('Set the confirm page in urls() or enable the package routes.');

Mail::to($event->entry->email)->send(new ConfirmWaitlistMail(
                $confirmUrl,
                $event->unsubscribeUrl,
                // A withdrawn optional purpose stays on a pending cycle.
                $event->subscription->consents->reject(fn ($consent) => $consent->isWithdrawn())->pluck('text')->values()->all(),
            ));
        } catch (Throwable $exception) {
            // The exception's class, never its message: that may hold the address.
            Waitlist::confirmationFailed($event, class_basename($exception));

            throw $exception;
        }

        // The reference of what you sent: bump the version when the mail changes.
        Waitlist::confirmationMailed($event, 'confirm-mail@1');
    }
}
```

- The same event carries resends (`$event->isNewCycle === false`). Do not throttle in
  the listener; the package's cooldown and caps already did.
- `confirmationMailed()` records the reference of the mail next to the consent;
  use a template name and version, and keep each version's text.
- A request counts against the cooldown and the caps when it is issued. The
  listener reports a send that threw with `Waitlist::confirmationFailed($event,
  'http-429')` (an error code, never the address) and rethrows: the person's own
  retry then gets a mail. It reports nothing after the mail is out. It lists only
  purposes not withdrawn, and the view renders them as Markdown: keep wording plain. Report with the event, never a model
  loaded afresh: each request takes one report, and a repeat, a report about an earlier request or a
  failure after `confirmationMailed()` answers `false`. A mail that went out after
  its failure was reported is accepted once.
- `$event->confirmUrl` is `null` while the project's `urls()` sets no `confirm` page
  and the package routes are off. A link that cannot be built (the routes are on
  but not registered, as after a stale route cache) throws
  `InvalidConfigurationException` and undoes the signup or confirmation: nothing is
  committed, so no event fires and no mail is due.

### 3. Preference page link listener

```php
use Taldres\Waitlist\Events\ManageLinkRequested;

class SendManageLinkMail implements ShouldQueue, ShouldBeEncrypted
{
    public function handle(ManageLinkRequested $event): void
    {
        Mail::to($event->entry->email)->send(new ManageLinkMail($event->manageUrl, $event->expiresAt));
    }
}
```

The link expires after `waitlist.manage.token_ttl` minutes (60); say so in the mail.

### 4. Launch mails and newsletters: ask `recipients()`

```php
use Taldres\Waitlist\Support\Recipient;

// one list, for its primary purpose: links leave that list
Waitlist::for('beta')->recipients()->each(
    fn (Recipient $recipient) => Mail::to($recipient->email)->locale($recipient->locale)->queue(new LaunchMail($recipient)),
);

// an optional purpose, project-wide, once per address: links withdraw only it
Waitlist::recipients('newsletter')->each(
    fn (Recipient $recipient) => Mail::to($recipient->email)->locale($recipient->locale)->queue(new NewsletterMail($recipient)),
);
```

In the Mailable, use the recipient's links:

```php
use Illuminate\Mail\Mailables\Headers;

public function __construct(public Recipient $recipient) {}

public function headers(): Headers
{
    return new Headers(text: $this->recipient->listUnsubscribeHeaders()); // RFC 8058 one-click
}

// content: $this->recipient->unsubscribeUrl()
```

`Recipient` has `entry`, `email`, `purpose`, `locale` (the consent's language, or
null) and `leavesList`. Queued Mailables carry the address and token links: let
them implement `ShouldBeEncrypted` too. One-click headers need the package routes and an `https`
`APP_URL`; without routes they are empty. A provider may refuse custom headers on
some plans (MailerSend answers a `422` below its Professional plan): the
confirmation mail needs none, and a mailable without `headers()` sends none.

### 5. A link outside events

```php
Waitlist::unsubscribeUrl($entry);                // leaves the list
Waitlist::unsubscribeUrl($entry, 'newsletter');  // withdraws only the newsletter
Waitlist::listUnsubscribeHeaders($entry, 'newsletter');
```

### 6. Several projects

The project is `$event->entry->project` (or `$recipient->entry->project`). Pick
sender and mailer from it:

```php
// config/products.php (the app's own): ['rocket' => ['name' => 'Rocket', 'from' => '[email protected]', 'mailer' => 'resend'], ...]
$product = config("products.{$event->entry->project}");

$mail = (new ConfirmWaitlistMail(...))->from($product['from'], $product['name']);
Mail::mailer($product['mailer'])->to($event->entry->email)->send($mail);
```

Mail links already point at each project's own pages (`urls()`).

## Rules, References, and Templates

- Every listener that sends mail implements `ShouldQueue` (identical response
  times for new and known addresses) and `ShouldBeEncrypted` (payloads hold
  tokens; `EntryForgotten` holds the address).
- Laravel discovers listeners in `app/Listeners` by their `handle()` type hint;
  registering them again with `Event::listen()` sends every mail twice.
- Events dispatch after commit; a dispatched event does not prove the mail went
  out, so monitor failed jobs.
- Mail driver setup: https://laravel.com/docs/mail; send from a domain with SPF,
  DKIM and DMARC.
- Full guide: https://github.com/Taldres/laravel-waitlist/blob/main/docs/mail.md

## Examples

- "Send the double opt-in mail with Resend": `composer require resend/resend-php`,
  `MAIL_MAILER=resend`, `RESEND_KEY=...`, then the listener from step 2.
- "Email everyone on the beta list that we launched": step 4 with
  `Waitlist::for('beta')->recipients()`.
- "Send the monthly newsletter": `Waitlist::recipients('newsletter')`, one-click
  headers per recipient.

## Anti-patterns

- Mailing `WaitlistEntry::all()` or `Waitlist::for($list)->entries()` without a
  consent check; use `recipients()` or `whereConsentedTo($purpose)`.
- Putting `Waitlist::manageLink($entry)` or a manage URL into ordinary mails; it
  opens the person's data and mails get forwarded.
- Sending the manage link anywhere but `$event->entry->email`, or answering the
  request with it.
- A newsletter link that unsubscribes from the whole list: use
  `unsubscribeUrl($entry, 'newsletter')` or the `Recipient`'s own link.
- Synchronous mail listeners, or queued ones without `ShouldBeEncrypted`.

Laravel Waitlist: mail

Use this skill when an application using taldres/laravel-waitlist needs to send any mail to people on a waitlist, or set up a mail provider for it.

Primary Goal

  • every mail the flow needs goes out from a queued, encrypted listener, carries the link that matches its purpose, and reaches only addresses with consent in force

Workflow

1. Know which mail is due when

The package sends no mail and calls no provider. It fires events; the app's listeners send through any Laravel mailer (Resend, Postmark, SES, SMTP, …).

Mail Due on Must carry
Confirmation (double opt-in) EntrySubscribed with $event->requiresConfirmation $event->confirmUrl, $event->unsubscribeUrl
Preference page link ManageLinkRequested $event->manageUrl, sent only to $event->entry->email
Welcome (optional) EntryConfirmed $event->unsubscribeUrl, Waitlist::listUnsubscribeHeaders($event->entry)
Still interested? (with retention.reconfirm_after_days) ReconfirmationRequested, fired by waitlist:prune once per period $event->confirmUrl (posted back like a confirmation), $event->unsubscribeUrl, $event->answerBy; without an answer the address lapses
Launch or update about one list the app decides per-recipient link that leaves the list
Newsletter (optional purpose) the app decides per-recipient link that withdraws only the newsletter

2. Confirmation listener

php artisan waitlist:install --mail writes the listener below, its ConfirmWaitlistMail and resources/views/mail/waitlist/confirm.blade.php into the app, skipping files that exist. Laravel discovers the listener; do not register it again (no listener is written while EntrySubscribed already has one: the command replaces a confirmation listener, it does not add to one). With event:cache, re-cache; with event discovery off, register it yourself. The files are the app's to edit afterwards.

namespace App\Listeners;

use App\Mail\ConfirmWaitlistMail;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;
use LogicException;
use Taldres\Waitlist\Events\EntrySubscribed;
use Taldres\Waitlist\Facades\Waitlist;
use Throwable;

class SendWaitlistConfirmationMail implements ShouldBeEncrypted, ShouldQueue
{
    public function handle(EntrySubscribed $event): void
    {
        if (! $event->requiresConfirmation) {
            return;
        }

        // Erased since the event: a retried job must not mail the address again.
        if ($event->entry->fresh() === null) {
            return;
        }

// Report only a send that did not happen. After the mail is out, a
        // failure must not free the cooldown, so it is not reported. A
        // MessageSent listener that throws counts as a failed send.
        try {
            // Null only while the project sets no confirm page and the package
            // routes are off: fail loudly rather than mail a confirmation without a link.
            $confirmUrl = $event->confirmUrl ?? throw new LogicException('Set the confirm page in urls() or enable the package routes.');

Mail::to($event->entry->email)->send(new ConfirmWaitlistMail(
                $confirmUrl,
                $event->unsubscribeUrl,
                // A withdrawn optional purpose stays on a pending cycle.
                $event->subscription->consents->reject(fn ($consent) => $consent->isWithdrawn())->pluck('text')->values()->all(),
            ));
        } catch (Throwable $exception) {
            // The exception's class, never its message: that may hold the address.
            Waitlist::confirmationFailed($event, class_basename($exception));

            throw $exception;
        }

        // The reference of what you sent: bump the version when the mail changes.
        Waitlist::confirmationMailed($event, 'confirm-mail@1');
    }
}
  • The same event carries resends ($event->isNewCycle === false). Do not throttle in the listener; the package's cooldown and caps already did.
  • confirmationMailed() records the reference of the mail next to the consent; use a template name and version, and keep each version's text.
  • A request counts against the cooldown and the caps when it is issued. The listener reports a send that threw with Waitlist::confirmationFailed($event, 'http-429') (an error code, never the address) and rethrows: the person's own retry then gets a mail. It reports nothing after the mail is out. It lists only purposes not withdrawn, and the view renders them as Markdown: keep wording plain. Report with the event, never a model loaded afresh: each request takes one report, and a repeat, a report about an earlier request or a failure after confirmationMailed() answers false. A mail that went out after its failure was reported is accepted once.
  • $event->confirmUrl is null while the project's urls() sets no confirm page and the package routes are off. A link that cannot be built (the routes are on but not registered, as after a stale route cache) throws InvalidConfigurationException and undoes the signup or confirmation: nothing is committed, so no event fires and no mail is due.

3. Preference page link listener

use Taldres\Waitlist\Events\ManageLinkRequested;

class SendManageLinkMail implements ShouldQueue, ShouldBeEncrypted
{
    public function handle(ManageLinkRequested $event): void
    {
        Mail::to($event->entry->email)->send(new ManageLinkMail($event->manageUrl, $event->expiresAt));
    }
}

The link expires after waitlist.manage.token_ttl minutes (60); say so in the mail.

4. Launch mails and newsletters: ask recipients()

use Taldres\Waitlist\Support\Recipient;

// one list, for its primary purpose: links leave that list
Waitlist::for('beta')->recipients()->each(
    fn (Recipient $recipient) => Mail::to($recipient->email)->locale($recipient->locale)->queue(new LaunchMail($recipient)),
);

// an optional purpose, project-wide, once per address: links withdraw only it
Waitlist::recipients('newsletter')->each(
    fn (Recipient $recipient) => Mail::to($recipient->email)->locale($recipient->locale)->queue(new NewsletterMail($recipient)),
);

In the Mailable, use the recipient's links:

use Illuminate\Mail\Mailables\Headers;

public function __construct(public Recipient $recipient) {}

public function headers(): Headers
{
    return new Headers(text: $this->recipient->listUnsubscribeHeaders()); // RFC 8058 one-click
}

// content: $this->recipient->unsubscribeUrl()

Recipient has entry, email, purpose, locale (the consent's language, or null) and leavesList. Queued Mailables carry the address and token links: let them implement ShouldBeEncrypted too. One-click headers need the package routes and an https APP_URL; without routes they are empty. A provider may refuse custom headers on some plans (MailerSend answers a 422 below its Professional plan): the confirmation mail needs none, and a mailable without headers() sends none.

5. A link outside events

Waitlist::unsubscribeUrl($entry);                // leaves the list
Waitlist::unsubscribeUrl($entry, 'newsletter');  // withdraws only the newsletter
Waitlist::listUnsubscribeHeaders($entry, 'newsletter');

6. Several projects

The project is $event->entry->project (or $recipient->entry->project). Pick sender and mailer from it:

// config/products.php (the app's own): ['rocket' => ['name' => 'Rocket', 'from' => '[email protected]', 'mailer' => 'resend'], ...]
$product = config("products.{$event->entry->project}");

$mail = (new ConfirmWaitlistMail(...))->from($product['from'], $product['name']);
Mail::mailer($product['mailer'])->to($event->entry->email)->send($mail);

Mail links already point at each project's own pages (urls()).

Rules, References, and Templates

  • Every listener that sends mail implements ShouldQueue (identical response times for new and known addresses) and ShouldBeEncrypted (payloads hold tokens; EntryForgotten holds the address).
  • Laravel discovers listeners in app/Listeners by their handle() type hint; registering them again with Event::listen() sends every mail twice.
  • Events dispatch after commit; a dispatched event does not prove the mail went out, so monitor failed jobs.
  • Mail driver setup: https://laravel.com/docs/mail; send from a domain with SPF, DKIM and DMARC.
  • Full guide: https://github.com/Taldres/laravel-waitlist/blob/main/docs/mail.md

Examples

  • "Send the double opt-in mail with Resend": composer require resend/resend-php, MAIL_MAILER=resend, RESEND_KEY=..., then the listener from step 2.
  • "Email everyone on the beta list that we launched": step 4 with Waitlist::for('beta')->recipients().
  • "Send the monthly newsletter": Waitlist::recipients('newsletter'), one-click headers per recipient.

Anti-patterns

  • Mailing WaitlistEntry::all() or Waitlist::for($list)->entries() without a consent check; use recipients() or whereConsentedTo($purpose).
  • Putting Waitlist::manageLink($entry) or a manage URL into ordinary mails; it opens the person's data and mails get forwarded.
  • Sending the manage link anywhere but $event->entry->email, or answering the request with it.
  • A newsletter link that unsubscribes from the whole list: use unsubscribeUrl($entry, 'newsletter') or the Recipient's own link.
  • Synchronous mail listeners, or queued ones without ShouldBeEncrypted.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log