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,178 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Email;
use App\Enums\EmailLogStatus;
use App\Enums\EmailTemplateType;
use App\Jobs\SendTransactionalEmail;
use App\Mail\TransactionalMail;
use App\Models\EmailLog;
use App\Models\Organisation;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class SendTransactionalEmailJobTest extends TestCase
{
use RefreshDatabase;
private array $template;
private array $branding;
protected function setUp(): void
{
parent::setUp();
$this->template = [
'subject' => 'Test Subject',
'heading' => 'Test Heading',
'body_text' => 'Test body text',
'button_text' => 'Click me',
'is_custom' => false,
];
$this->branding = [
'logo_url' => null,
'primary_color' => '#6366F1',
'secondary_color' => '#4F46E5',
'footer_text' => '© 2026 Crewli',
'reply_to_email' => null,
'reply_to_name' => null,
];
}
public function test_job_sends_email_and_updates_log_to_sent(): void
{
Mail::fake();
$org = Organisation::factory()->create();
$log = EmailLog::factory()->create([
'organisation_id' => $org->id,
'status' => EmailLogStatus::QUEUED->value,
]);
$job = new SendTransactionalEmail(
emailLogId: $log->id,
type: EmailTemplateType::INVITATION,
recipientEmail: 'test@example.com',
recipientName: 'Test User',
template: $this->template,
branding: $this->branding,
actionUrl: 'https://example.com/action',
);
$job->handle();
Mail::assertSent(TransactionalMail::class, function ($mailable) {
return $mailable->template['subject'] === 'Test Subject';
});
$log->refresh();
$this->assertEquals(EmailLogStatus::SENT, $log->status);
$this->assertNotNull($log->sent_at);
}
public function test_job_skips_already_sent_emails(): void
{
Mail::fake();
$org = Organisation::factory()->create();
$log = EmailLog::factory()->sent()->create([
'organisation_id' => $org->id,
]);
$job = new SendTransactionalEmail(
emailLogId: $log->id,
type: EmailTemplateType::INVITATION,
recipientEmail: 'test@example.com',
recipientName: 'Test User',
template: $this->template,
branding: $this->branding,
);
$job->handle();
Mail::assertNothingSent();
}
public function test_job_updates_log_to_failed_on_exception(): void
{
Mail::fake();
Mail::shouldReceive('to')->andThrow(new \RuntimeException('SMTP error'));
$org = Organisation::factory()->create();
$log = EmailLog::factory()->create([
'organisation_id' => $org->id,
'status' => EmailLogStatus::QUEUED->value,
]);
$job = new SendTransactionalEmail(
emailLogId: $log->id,
type: EmailTemplateType::INVITATION,
recipientEmail: 'test@example.com',
recipientName: 'Test User',
template: $this->template,
branding: $this->branding,
);
try {
$job->handle();
} catch (\RuntimeException) {
// Expected
}
$log->refresh();
$this->assertEquals(EmailLogStatus::FAILED, $log->status);
$this->assertNotNull($log->failed_at);
$this->assertStringContainsString('SMTP error', $log->error_message);
}
public function test_job_retries_on_failure(): void
{
$job = new SendTransactionalEmail(
emailLogId: 'fake-id',
type: EmailTemplateType::INVITATION,
recipientEmail: 'test@example.com',
recipientName: 'Test User',
template: $this->template,
branding: $this->branding,
);
$this->assertEquals(3, $job->tries);
$this->assertEquals([30, 120, 300], $job->backoff);
$this->assertEquals('emails', $job->queue);
}
public function test_job_sets_reply_to_when_configured(): void
{
Mail::fake();
$org = Organisation::factory()->create();
$log = EmailLog::factory()->create([
'organisation_id' => $org->id,
'status' => EmailLogStatus::QUEUED->value,
]);
$brandingWithReplyTo = array_merge($this->branding, [
'reply_to_email' => 'reply@example.com',
'reply_to_name' => 'Reply User',
]);
$job = new SendTransactionalEmail(
emailLogId: $log->id,
type: EmailTemplateType::INVITATION,
recipientEmail: 'test@example.com',
recipientName: 'Test User',
template: $this->template,
branding: $brandingWithReplyTo,
);
$job->handle();
Mail::assertSent(TransactionalMail::class, function ($mailable) {
return $mailable->hasReplyTo('reply@example.com', 'Reply User');
});
}
}