feat: complete email infrastructure with queue, templates, logging, and API

Adds the full transactional email system:
- Redis queue (QUEUE_CONNECTION=redis), SES config in .env.example
- 3 migrations: organisation_email_settings, organisation_email_templates, email_logs
- EmailTemplateType and EmailLogStatus enums with Dutch defaults
- EmailService as central entry point for all email sending
- SendTransactionalEmail queued job with retries and idempotency
- TransactionalMail mailable with responsive HTML + plain text templates
- Organisation-level branding (colors, logo, footer, reply-to)
- Per-type template overrides with {variable} substitution
- Email log with filtering by status, type, date range, recipient
- Preview and send-test endpoints for template management
- API endpoints: email-settings, email-templates (CRUD), email-logs (read-only)
- Integrated into existing flows: invitations, password reset, email
  verification, registration approval/rejection
- 37 new tests across 4 test files, all existing tests updated

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 20:12:21 +02:00
parent c64875b6ef
commit 65978104d8
42 changed files with 2420 additions and 48 deletions

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum EmailLogStatus: string
{
case QUEUED = 'queued';
case SENT = 'sent';
case FAILED = 'failed';
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum EmailTemplateType: string
{
case INVITATION = 'invitation';
case PASSWORD_RESET = 'password_reset';
case EMAIL_VERIFICATION = 'email_verification';
case REGISTRATION_APPROVED = 'registration_approved';
case REGISTRATION_REJECTED = 'registration_rejected';
case SHIFT_ASSIGNMENT = 'shift_assignment';
public function label(): string
{
return match ($this) {
self::INVITATION => 'Uitnodiging',
self::PASSWORD_RESET => 'Wachtwoord resetten',
self::EMAIL_VERIFICATION => 'E-mailadres verificatie',
self::REGISTRATION_APPROVED => 'Registratie goedgekeurd',
self::REGISTRATION_REJECTED => 'Registratie afgewezen',
self::SHIFT_ASSIGNMENT => 'Diensttoewijzing',
};
}
/**
* Default template content per type (Dutch).
* Used when no organisation-specific override exists.
*
* @return array{subject: string, heading: string, body_text: string, button_text: string|null}
*/
public function defaults(): array
{
return match ($this) {
self::INVITATION => [
'subject' => 'Je bent uitgenodigd voor {organisation_name}',
'heading' => 'Welkom bij {organisation_name}!',
'body_text' => 'Je bent uitgenodigd om lid te worden van {organisation_name} op Crewli. Klik op de knop hieronder om je uitnodiging te accepteren en een account aan te maken.',
'button_text' => 'Uitnodiging accepteren',
],
self::PASSWORD_RESET => [
'subject' => 'Wachtwoord resetten',
'heading' => 'Wachtwoord resetten',
'body_text' => 'Je hebt een verzoek ingediend om je wachtwoord te resetten. Klik op de knop hieronder om een nieuw wachtwoord in te stellen. Deze link is 60 minuten geldig.',
'button_text' => 'Wachtwoord resetten',
],
self::EMAIL_VERIFICATION => [
'subject' => 'Bevestig je nieuwe e-mailadres',
'heading' => 'E-mailadres bevestigen',
'body_text' => 'Klik op de knop hieronder om je nieuwe e-mailadres te bevestigen. Als je dit niet hebt aangevraagd, kun je deze email negeren.',
'button_text' => 'E-mailadres bevestigen',
],
self::REGISTRATION_APPROVED => [
'subject' => 'Je registratie voor {event_name} is goedgekeurd!',
'heading' => 'Welkom aan boord!',
'body_text' => 'Goed nieuws! Je registratie als vrijwilliger voor {event_name} is goedgekeurd. Je kunt nu inloggen op het portaal om je diensten te bekijken en te claimen.',
'button_text' => 'Ga naar het portaal',
],
self::REGISTRATION_REJECTED => [
'subject' => 'Update over je registratie voor {event_name}',
'heading' => 'Helaas...',
'body_text' => 'Bedankt voor je interesse om als vrijwilliger mee te helpen bij {event_name}. Helaas kunnen we je registratie op dit moment niet goedkeuren. Neem contact op met de organisatie als je vragen hebt.',
'button_text' => null,
],
self::SHIFT_ASSIGNMENT => [
'subject' => 'Je bent ingedeeld voor een dienst bij {event_name}',
'heading' => 'Nieuwe diensttoewijzing',
'body_text' => 'Je bent ingedeeld voor de volgende dienst: {shift_title} op {shift_date} van {shift_start} tot {shift_end} bij {section_name}. Log in op het portaal voor meer details.',
'button_text' => 'Bekijk je diensten',
],
};
}
}