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:
178
api/tests/Feature/Email/SendTransactionalEmailJobTest.php
Normal file
178
api/tests/Feature/Email/SendTransactionalEmailJobTest.php
Normal 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');
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user