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,81 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $heading ?? '' }}</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
</head>
<body style="margin: 0; padding: 0; background-color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f5;">
<tr>
<td align="center" style="padding: 32px 16px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width: 600px; width: 100%; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
{{-- Accent line --}}
<tr>
<td style="height: 4px; background-color: {{ $primaryColor }};"></td>
</tr>
{{-- Header: logo or Crewli text --}}
<tr>
<td align="center" style="padding: 32px 40px 24px;">
@if($logoUrl)
<img src="{{ $logoUrl }}" alt="Logo" style="max-height: 60px; max-width: 250px; display: block;">
@else
<p style="margin: 0; font-size: 20px; color: #1f2937; font-weight: 700;">Crewli</p>
@endif
</td>
</tr>
{{-- Body --}}
<tr>
<td style="padding: 0 40px 32px;">
{{-- Heading --}}
@if($heading)
<h1 style="margin: 0 0 20px; font-size: 22px; font-weight: 700; color: #1f2937; line-height: 1.3;">
{{ $heading }}
</h1>
@endif
{{-- Body text --}}
<div style="font-size: 16px; color: #1f2937; line-height: 1.6;">
{!! nl2br(e($bodyText)) !!}
</div>
{{-- CTA button --}}
@if($buttonText && $actionUrl)
<div style="margin-top: 28px; text-align: center;">
<a href="{{ $actionUrl }}" style="display: inline-block; padding: 12px 24px; background-color: {{ $primaryColor }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 600;">{{ $buttonText }}</a>
</div>
@endif
</td>
</tr>
{{-- Footer --}}
<tr>
<td style="padding: 24px 40px; border-top: 1px solid #e5e7eb;">
@if($footerText)
<p style="margin: 0 0 16px; font-size: 13px; color: #6b7280; text-align: center; line-height: 1.5;">
{{ $footerText }}
</p>
@endif
<p style="margin: 0; font-size: 11px; color: #9ca3af; text-align: center; line-height: 1.5;">
Powered by Crewli
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,12 @@
@if($heading)
{{ $heading }}
@endif
{{ $bodyText }}
@if($buttonText && $actionUrl)
{{ $buttonText }}: {{ $actionUrl }}
@endif
---
{{ $footerText ?? 'Crewli' }}