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.
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`.
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.
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, …).
| 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 |
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');
}
}
$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.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.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.
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.
Waitlist::unsubscribeUrl($entry); // leaves the list
Waitlist::unsubscribeUrl($entry, 'newsletter'); // withdraws only the newsletter
Waitlist::listUnsubscribeHeaders($entry, 'newsletter');
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()).
ShouldQueue (identical response
times for new and known addresses) and ShouldBeEncrypted (payloads hold
tokens; EntryForgotten holds the address).app/Listeners by their handle() type hint;
registering them again with Event::listen() sends every mail twice.composer require resend/resend-php,
MAIL_MAILER=resend, RESEND_KEY=..., then the listener from step 2.Waitlist::for('beta')->recipients().Waitlist::recipients('newsletter'), one-click
headers per recipient.WaitlistEntry::all() or Waitlist::for($list)->entries() without a
consent check; use recipients() or whereConsentedTo($purpose).Waitlist::manageLink($entry) or a manage URL into ordinary mails; it
opens the person's data and mails get forwarded.$event->entry->email, or answering the
request with it.unsubscribeUrl($entry, 'newsletter') or the Recipient's own link.ShouldBeEncrypted.How can I help you explore Laravel packages today?