From 65978104d89bfeffd4762511587d72caff6b1dc1 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Wed, 15 Apr 2026 20:12:21 +0200 Subject: [PATCH] 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) --- api/.env.example | 9 +- api/app/Enums/EmailLogStatus.php | 12 + api/app/Enums/EmailTemplateType.php | 75 ++++++ .../Controllers/Api/V1/EmailLogController.php | 63 +++++ .../OrganisationEmailSettingsController.php | 52 ++++ .../OrganisationEmailTemplateController.php | 188 ++++++++++++++ .../Api/V1/PasswordResetController.php | 18 +- .../Controllers/Api/V1/PersonController.php | 37 ++- .../Api/V1/UpdateEmailSettingsRequest.php | 27 ++ .../Api/V1/UpdateEmailTemplateRequest.php | 25 ++ .../Resources/Api/V1/EmailLogResource.php | 35 +++ .../Api/V1/EmailSettingsResource.php | 27 ++ .../Api/V1/EmailTemplateResource.php | 37 +++ api/app/Jobs/SendTransactionalEmail.php | 83 +++++++ api/app/Mail/TransactionalMail.php | 48 ++++ api/app/Models/EmailLog.php | 72 ++++++ api/app/Models/Organisation.php | 16 ++ api/app/Models/OrganisationEmailSettings.php | 31 +++ api/app/Models/OrganisationEmailTemplate.php | 38 +++ api/app/Services/EmailChangeService.php | 23 +- api/app/Services/EmailService.php | 169 +++++++++++++ api/app/Services/InvitationService.php | 19 +- api/database/factories/EmailLogFactory.php | 55 ++++ .../OrganisationEmailSettingsFactory.php | 28 +++ .../OrganisationEmailTemplateFactory.php | 35 +++ ...eate_organisation_email_settings_table.php | 34 +++ ...ate_organisation_email_templates_table.php | 33 +++ ...6_04_15_100002_create_email_logs_table.php | 49 ++++ .../views/emails/transactional.blade.php | 81 ++++++ .../views/emails/transactional_text.blade.php | 12 + api/routes/api.php | 17 ++ api/tests/Feature/Api/V1/EmailChangeTest.php | 18 +- .../Feature/Api/V1/PasswordResetTest.php | 14 +- .../Api/V1/PersonApprovalEmailTest.php | 34 +-- .../Feature/Email/EmailLogControllerTest.php | 162 ++++++++++++ api/tests/Feature/Email/EmailServiceTest.php | 173 +++++++++++++ ...EmailSettingsAndTemplateControllerTest.php | 235 ++++++++++++++++++ .../Email/SendTransactionalEmailJobTest.php | 178 +++++++++++++ .../Feature/Invitation/InvitationTest.php | 10 +- .../Security/AuthenticationSecurityTest.php | 5 + dev-docs/API.md | 118 +++++++++ dev-docs/SCHEMA.md | 73 ++++++ 42 files changed, 2420 insertions(+), 48 deletions(-) create mode 100644 api/app/Enums/EmailLogStatus.php create mode 100644 api/app/Enums/EmailTemplateType.php create mode 100644 api/app/Http/Controllers/Api/V1/EmailLogController.php create mode 100644 api/app/Http/Controllers/Api/V1/OrganisationEmailSettingsController.php create mode 100644 api/app/Http/Controllers/Api/V1/OrganisationEmailTemplateController.php create mode 100644 api/app/Http/Requests/Api/V1/UpdateEmailSettingsRequest.php create mode 100644 api/app/Http/Requests/Api/V1/UpdateEmailTemplateRequest.php create mode 100644 api/app/Http/Resources/Api/V1/EmailLogResource.php create mode 100644 api/app/Http/Resources/Api/V1/EmailSettingsResource.php create mode 100644 api/app/Http/Resources/Api/V1/EmailTemplateResource.php create mode 100644 api/app/Jobs/SendTransactionalEmail.php create mode 100644 api/app/Mail/TransactionalMail.php create mode 100644 api/app/Models/EmailLog.php create mode 100644 api/app/Models/OrganisationEmailSettings.php create mode 100644 api/app/Models/OrganisationEmailTemplate.php create mode 100644 api/app/Services/EmailService.php create mode 100644 api/database/factories/EmailLogFactory.php create mode 100644 api/database/factories/OrganisationEmailSettingsFactory.php create mode 100644 api/database/factories/OrganisationEmailTemplateFactory.php create mode 100644 api/database/migrations/2026_04_15_100000_create_organisation_email_settings_table.php create mode 100644 api/database/migrations/2026_04_15_100001_create_organisation_email_templates_table.php create mode 100644 api/database/migrations/2026_04_15_100002_create_email_logs_table.php create mode 100644 api/resources/views/emails/transactional.blade.php create mode 100644 api/resources/views/emails/transactional_text.blade.php create mode 100644 api/tests/Feature/Email/EmailLogControllerTest.php create mode 100644 api/tests/Feature/Email/EmailServiceTest.php create mode 100644 api/tests/Feature/Email/EmailSettingsAndTemplateControllerTest.php create mode 100644 api/tests/Feature/Email/SendTransactionalEmailJobTest.php diff --git a/api/.env.example b/api/.env.example index bca95e94..03222877 100644 --- a/api/.env.example +++ b/api/.env.example @@ -35,7 +35,7 @@ SESSION_DOMAIN=localhost BROADCAST_CONNECTION=log FILESYSTEM_DISK=local -QUEUE_CONNECTION=database +QUEUE_CONNECTION=redis CACHE_STORE=redis @@ -44,6 +44,7 @@ REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 +# Mail — Local development (Mailpit) MAIL_MAILER=smtp MAIL_HOST=127.0.0.1 MAIL_PORT=1025 @@ -54,6 +55,12 @@ MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="noreply@crewli.app" MAIL_FROM_NAME="${APP_NAME}" +# --- Production mail: Amazon SES — uncomment and configure: +# MAIL_MAILER=ses +# AWS_ACCESS_KEY_ID= +# AWS_SECRET_ACCESS_KEY= +# AWS_DEFAULT_REGION=eu-west-1 + # CORS + Sanctum — SPA origins (no trailing slash; must match the browser URL) FRONTEND_APP_URL=http://localhost:5174 FRONTEND_PORTAL_URL=http://localhost:5175 diff --git a/api/app/Enums/EmailLogStatus.php b/api/app/Enums/EmailLogStatus.php new file mode 100644 index 00000000..a1e0658f --- /dev/null +++ b/api/app/Enums/EmailLogStatus.php @@ -0,0 +1,12 @@ + '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', + ], + }; + } +} diff --git a/api/app/Http/Controllers/Api/V1/EmailLogController.php b/api/app/Http/Controllers/Api/V1/EmailLogController.php new file mode 100644 index 00000000..d7c4cd75 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/EmailLogController.php @@ -0,0 +1,63 @@ +id) + ->with('triggeredBy') + ->orderByDesc('created_at'); + + if ($search = $request->query('search')) { + $query->where('recipient_email', 'like', '%' . $search . '%'); + } + + if ($status = $request->query('status')) { + if (EmailLogStatus::tryFrom($status)) { + $query->where('status', $status); + } + } + + if ($templateType = $request->query('template_type')) { + if (EmailTemplateType::tryFrom($templateType)) { + $query->where('template_type', $templateType); + } + } + + if ($eventId = $request->query('event_id')) { + $query->where('event_id', $eventId); + } + + if ($personId = $request->query('person_id')) { + $query->where('person_id', $personId); + } + + if ($from = $request->query('from')) { + $query->where('created_at', '>=', $from); + } + + if ($to = $request->query('to')) { + $query->where('created_at', '<=', $to); + } + + $logs = $query->paginate($request->integer('per_page', 15)); + + return $this->success(EmailLogResource::collection($logs)->response()->getData(true)); + } +} diff --git a/api/app/Http/Controllers/Api/V1/OrganisationEmailSettingsController.php b/api/app/Http/Controllers/Api/V1/OrganisationEmailSettingsController.php new file mode 100644 index 00000000..908072d9 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/OrganisationEmailSettingsController.php @@ -0,0 +1,52 @@ +emailSettings; + + if (! $settings) { + // Return defaults when no custom settings exist + return $this->success($this->emailService->resolveBranding($organisation)); + } + + return $this->success(new EmailSettingsResource($settings)); + } + + public function update(UpdateEmailSettingsRequest $request, Organisation $organisation): JsonResponse + { + Gate::authorize('update', $organisation); + + $settings = OrganisationEmailSettings::updateOrCreate( + ['organisation_id' => $organisation->id], + $request->validated(), + ); + + activity('email_settings') + ->performedOn($settings) + ->causedBy($request->user()) + ->log('email_settings.updated'); + + return $this->success(new EmailSettingsResource($settings->fresh())); + } +} diff --git a/api/app/Http/Controllers/Api/V1/OrganisationEmailTemplateController.php b/api/app/Http/Controllers/Api/V1/OrganisationEmailTemplateController.php new file mode 100644 index 00000000..f3aac1ed --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/OrganisationEmailTemplateController.php @@ -0,0 +1,188 @@ +emailService->getAllTemplates($organisation); + + return $this->success($templates); + } + + public function show(Organisation $organisation, string $type): JsonResponse + { + Gate::authorize('update', $organisation); + + $templateType = $this->resolveType($type); + + $template = $this->emailService->resolveTemplate($templateType, $organisation); + $template['type'] = $templateType->value; + $template['label'] = $templateType->label(); + $template['defaults'] = $templateType->defaults(); + + return $this->success($template); + } + + public function update(UpdateEmailTemplateRequest $request, Organisation $organisation, string $type): JsonResponse + { + Gate::authorize('update', $organisation); + + $templateType = $this->resolveType($type); + + $template = OrganisationEmailTemplate::updateOrCreate( + [ + 'organisation_id' => $organisation->id, + 'type' => $templateType->value, + ], + $request->validated(), + ); + + activity('email_template') + ->performedOn($template) + ->causedBy($request->user()) + ->withProperties(['type' => $templateType->value]) + ->log('email_template.updated'); + + $result = $this->emailService->resolveTemplate($templateType, $organisation); + $result['type'] = $templateType->value; + $result['label'] = $templateType->label(); + $result['defaults'] = $templateType->defaults(); + + return $this->success($result); + } + + public function destroy(Organisation $organisation, string $type): JsonResponse + { + Gate::authorize('update', $organisation); + + $templateType = $this->resolveType($type); + + OrganisationEmailTemplate::where('organisation_id', $organisation->id) + ->where('type', $templateType->value) + ->delete(); + + activity('email_template') + ->causedBy(request()->user()) + ->withProperties(['type' => $templateType->value]) + ->log('email_template.reset_to_default'); + + return $this->success(message: 'Template reset naar standaard.'); + } + + public function preview(Organisation $organisation, string $type): JsonResponse + { + Gate::authorize('update', $organisation); + + $templateType = $this->resolveType($type); + + $sampleVariables = [ + 'organisation_name' => $organisation->name, + 'event_name' => 'Voorbeeldevenement', + 'shift_title' => 'Bar medewerker', + 'shift_date' => '15 juni 2026', + 'shift_start' => '14:00', + 'shift_end' => '22:00', + 'section_name' => 'Hoofdpodium Bar', + ]; + + $template = $this->emailService->resolveTemplate($templateType, $organisation); + + // Substitute sample variables + foreach ($template as $key => $value) { + if (is_string($value)) { + foreach ($sampleVariables as $var => $replacement) { + $value = str_replace('{' . $var . '}', $replacement, $value); + } + $template[$key] = $value; + } + } + + $branding = $this->emailService->resolveBranding($organisation); + + $html = View::make('emails.transactional', [ + 'heading' => $template['heading'], + 'bodyText' => $template['body_text'], + 'buttonText' => $template['button_text'], + 'actionUrl' => 'https://crewli.app/example', + 'logoUrl' => $branding['logo_url'], + 'primaryColor' => $branding['primary_color'], + 'secondaryColor' => $branding['secondary_color'], + 'footerText' => $branding['footer_text'], + ])->render(); + + return $this->success(['html' => $html]); + } + + public function sendTest(Request $request, Organisation $organisation, string $type): JsonResponse + { + Gate::authorize('update', $organisation); + + $request->validate([ + 'email' => ['required', 'email'], + ]); + + $templateType = $this->resolveType($type); + + $sampleVariables = [ + 'organisation_name' => $organisation->name, + 'event_name' => 'Voorbeeldevenement', + 'shift_title' => 'Bar medewerker', + 'shift_date' => '15 juni 2026', + 'shift_start' => '14:00', + 'shift_end' => '22:00', + 'section_name' => 'Hoofdpodium Bar', + ]; + + $this->emailService->send( + type: $templateType, + recipientEmail: $request->input('email'), + recipientName: 'Test Ontvanger', + variables: $sampleVariables, + actionUrl: 'https://crewli.app/example', + organisation: $organisation, + triggeredByUserId: $request->user()->id, + ); + + activity('email_template') + ->causedBy($request->user()) + ->withProperties([ + 'type' => $templateType->value, + 'test_email' => $request->input('email'), + ]) + ->log('email.test_sent'); + + return $this->success(message: 'Testmail verzonden naar ' . $request->input('email') . '.'); + } + + private function resolveType(string $type): EmailTemplateType + { + $templateType = EmailTemplateType::tryFrom($type); + + if (! $templateType) { + abort(404, 'Onbekend template type.'); + } + + return $templateType; + } +} diff --git a/api/app/Http/Controllers/Api/V1/PasswordResetController.php b/api/app/Http/Controllers/Api/V1/PasswordResetController.php index 6173c347..62338afc 100644 --- a/api/app/Http/Controllers/Api/V1/PasswordResetController.php +++ b/api/app/Http/Controllers/Api/V1/PasswordResetController.php @@ -4,9 +4,10 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; +use App\Enums\EmailTemplateType; use App\Http\Controllers\Controller; use App\Models\User; -use App\Notifications\ResetPasswordNotification; +use App\Services\EmailService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; @@ -15,6 +16,10 @@ use Illuminate\Validation\Rules\Password as PasswordRule; final class PasswordResetController extends Controller { + public function __construct( + private readonly EmailService $emailService, + ) {} + public function sendResetLink(Request $request): JsonResponse { $request->validate([ @@ -33,7 +38,16 @@ final class PasswordResetController extends Controller Password::sendResetLink( ['email' => strtolower($request->email)], function (User $user, string $token) use ($frontendUrl) { - $user->notify(new ResetPasswordNotification($token, $frontendUrl)); + $organisation = $user->organisations()->first(); + + $this->emailService->send( + type: EmailTemplateType::PASSWORD_RESET, + recipientEmail: $user->email, + recipientName: $user->first_name . ' ' . $user->last_name, + actionUrl: $frontendUrl . '/reset-password?token=' . $token . '&email=' . urlencode($user->email), + organisation: $organisation, + userId: $user->id, + ); } ); diff --git a/api/app/Http/Controllers/Api/V1/PersonController.php b/api/app/Http/Controllers/Api/V1/PersonController.php index abfb0a35..372c8dd4 100644 --- a/api/app/Http/Controllers/Api/V1/PersonController.php +++ b/api/app/Http/Controllers/Api/V1/PersonController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; +use App\Enums\EmailTemplateType; use App\Enums\PersonStatus; use App\Http\Controllers\Controller; use App\Http\Controllers\Api\V1\Traits\VerifiesOrganisationEvent; @@ -12,18 +13,16 @@ use App\Http\Requests\Api\V1\StorePersonRequest; use App\Http\Requests\Api\V1\UpdatePersonRequest; use App\Http\Resources\Api\V1\PersonCollection; use App\Http\Resources\Api\V1\PersonResource; -use App\Mail\RegistrationApprovedMail; -use App\Mail\RegistrationRejectedMail; use App\Models\Event; use App\Models\Organisation; use App\Models\Person; use App\Models\User; +use App\Services\EmailService; use App\Services\PersonIdentityService; use App\Services\TagSyncService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; -use Illuminate\Support\Facades\Mail; use Illuminate\Validation\ValidationException; final class PersonController extends Controller @@ -33,6 +32,7 @@ final class PersonController extends Controller public function __construct( private readonly PersonIdentityService $identityService, private readonly TagSyncService $tagSyncService, + private readonly EmailService $emailService, ) {} public function index(Request $request, Organisation $organisation, Event $event): PersonCollection @@ -169,7 +169,20 @@ final class PersonController extends Controller $this->tagSyncService->syncFromRegistration($person); if ($person->email) { - Mail::to($person->email)->queue(new RegistrationApprovedMail($person, $event)); + $this->emailService->send( + type: EmailTemplateType::REGISTRATION_APPROVED, + recipientEmail: $person->email, + recipientName: trim($person->first_name . ' ' . $person->last_name), + variables: [ + 'event_name' => $event->name, + 'organisation_name' => $organisation->name, + ], + actionUrl: config('app.frontend_portal_url'), + organisation: $organisation, + eventId: $event->id, + personId: $person->id, + triggeredByUserId: auth()->id(), + ); } return $this->success(new PersonResource($person->fresh()->load('crowdType'))); @@ -182,10 +195,20 @@ final class PersonController extends Controller $person->update(['status' => 'rejected']); - $reason = $request->input('reason'); - if ($person->email) { - Mail::to($person->email)->queue(new RegistrationRejectedMail($person, $event, $reason)); + $this->emailService->send( + type: EmailTemplateType::REGISTRATION_REJECTED, + recipientEmail: $person->email, + recipientName: trim($person->first_name . ' ' . $person->last_name), + variables: [ + 'event_name' => $event->name, + 'organisation_name' => $organisation->name, + ], + organisation: $organisation, + eventId: $event->id, + personId: $person->id, + triggeredByUserId: auth()->id(), + ); } return $this->success(new PersonResource($person->fresh()->load('crowdType'))); diff --git a/api/app/Http/Requests/Api/V1/UpdateEmailSettingsRequest.php b/api/app/Http/Requests/Api/V1/UpdateEmailSettingsRequest.php new file mode 100644 index 00000000..63bb3836 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/UpdateEmailSettingsRequest.php @@ -0,0 +1,27 @@ + ['nullable', 'url', 'max:500'], + 'primary_color' => ['nullable', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'secondary_color' => ['nullable', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'footer_text' => ['nullable', 'string', 'max:200'], + 'reply_to_email' => ['nullable', 'email'], + 'reply_to_name' => ['nullable', 'string', 'max:100'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/UpdateEmailTemplateRequest.php b/api/app/Http/Requests/Api/V1/UpdateEmailTemplateRequest.php new file mode 100644 index 00000000..544c99f6 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/UpdateEmailTemplateRequest.php @@ -0,0 +1,25 @@ + ['required', 'string', 'max:200'], + 'heading' => ['nullable', 'string', 'max:200'], + 'body_text' => ['required', 'string', 'max:5000'], + 'button_text' => ['nullable', 'string', 'max:100'], + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/EmailLogResource.php b/api/app/Http/Resources/Api/V1/EmailLogResource.php new file mode 100644 index 00000000..c42f3485 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/EmailLogResource.php @@ -0,0 +1,35 @@ + $this->id, + 'recipient_email' => $this->recipient_email, + 'recipient_name' => $this->recipient_name, + 'template_type' => $this->template_type->value, + 'template_label' => $this->template_type->label(), + 'subject' => $this->subject, + 'status' => $this->status->value, + 'error_message' => $this->error_message, + 'queued_at' => $this->queued_at?->toIso8601String(), + 'sent_at' => $this->sent_at?->toIso8601String(), + 'failed_at' => $this->failed_at?->toIso8601String(), + 'triggered_by' => $this->whenLoaded('triggeredBy', fn () => [ + 'id' => $this->triggeredBy->id, + 'name' => $this->triggeredBy->first_name . ' ' . $this->triggeredBy->last_name, + ]), + 'event_id' => $this->event_id, + 'person_id' => $this->person_id, + 'created_at' => $this->created_at->toIso8601String(), + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/EmailSettingsResource.php b/api/app/Http/Resources/Api/V1/EmailSettingsResource.php new file mode 100644 index 00000000..ac8972a4 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/EmailSettingsResource.php @@ -0,0 +1,27 @@ + $this->id, + 'organisation_id' => $this->organisation_id, + 'logo_url' => $this->logo_url, + 'primary_color' => $this->primary_color, + 'secondary_color' => $this->secondary_color, + 'footer_text' => $this->footer_text, + 'reply_to_email' => $this->reply_to_email, + 'reply_to_name' => $this->reply_to_name, + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/EmailTemplateResource.php b/api/app/Http/Resources/Api/V1/EmailTemplateResource.php new file mode 100644 index 00000000..b4bb5944 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/EmailTemplateResource.php @@ -0,0 +1,37 @@ + + */ + public function toArray(Request $request): array + { + // This resource handles both Eloquent models and raw arrays from EmailService + if (is_array($this->resource)) { + return $this->resource; + } + + return [ + 'id' => $this->id, + 'organisation_id' => $this->organisation_id, + 'type' => $this->type instanceof \App\Enums\EmailTemplateType ? $this->type->value : $this->type, + 'label' => $this->type instanceof \App\Enums\EmailTemplateType ? $this->type->label() : $this->type, + 'subject' => $this->subject, + 'heading' => $this->heading, + 'body_text' => $this->body_text, + 'button_text' => $this->button_text, + 'is_custom' => true, + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ]; + } +} diff --git a/api/app/Jobs/SendTransactionalEmail.php b/api/app/Jobs/SendTransactionalEmail.php new file mode 100644 index 00000000..6b984978 --- /dev/null +++ b/api/app/Jobs/SendTransactionalEmail.php @@ -0,0 +1,83 @@ + */ + public array $backoff = [30, 120, 300]; + + public function __construct( + public readonly string $emailLogId, + public readonly EmailTemplateType $type, + public readonly string $recipientEmail, + public readonly string $recipientName, + public readonly array $template, + public readonly array $branding, + public readonly ?string $actionUrl = null, + ) { + $this->onQueue('emails'); + } + + public function handle(): void + { + $emailLog = EmailLog::find($this->emailLogId); + + // Idempotency: don't re-send if already sent or missing + if (! $emailLog || $emailLog->status === EmailLogStatus::SENT) { + return; + } + + try { + $mailable = new TransactionalMail( + template: $this->template, + branding: $this->branding, + actionUrl: $this->actionUrl, + ); + + if ($this->branding['reply_to_email']) { + $mailable->replyTo( + $this->branding['reply_to_email'], + $this->branding['reply_to_name'], + ); + } + + Mail::to($this->recipientEmail, $this->recipientName) + ->send($mailable); + + $emailLog->update([ + 'status' => EmailLogStatus::SENT->value, + 'sent_at' => now(), + ]); + } catch (\Exception $e) { + $emailLog->update([ + 'status' => EmailLogStatus::FAILED->value, + 'failed_at' => now(), + 'error_message' => Str::limit($e->getMessage(), 500), + ]); + + throw $e; + } + } +} diff --git a/api/app/Mail/TransactionalMail.php b/api/app/Mail/TransactionalMail.php new file mode 100644 index 00000000..beace31e --- /dev/null +++ b/api/app/Mail/TransactionalMail.php @@ -0,0 +1,48 @@ +template['subject'], + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.transactional', + text: 'emails.transactional_text', + with: [ + 'heading' => $this->template['heading'], + 'bodyText' => $this->template['body_text'], + 'buttonText' => $this->template['button_text'], + 'actionUrl' => $this->actionUrl, + 'logoUrl' => $this->branding['logo_url'], + 'primaryColor' => $this->branding['primary_color'], + 'secondaryColor' => $this->branding['secondary_color'], + 'footerText' => $this->branding['footer_text'], + ], + ); + } +} diff --git a/api/app/Models/EmailLog.php b/api/app/Models/EmailLog.php new file mode 100644 index 00000000..e24cbe6f --- /dev/null +++ b/api/app/Models/EmailLog.php @@ -0,0 +1,72 @@ + EmailTemplateType::class, + 'status' => EmailLogStatus::class, + 'queued_at' => 'datetime', + 'sent_at' => 'datetime', + 'failed_at' => 'datetime', + ]; + } + + public function organisation(): BelongsTo + { + return $this->belongsTo(Organisation::class); + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function person(): BelongsTo + { + return $this->belongsTo(Person::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function triggeredBy(): BelongsTo + { + return $this->belongsTo(User::class, 'triggered_by_user_id'); + } +} diff --git a/api/app/Models/Organisation.php b/api/app/Models/Organisation.php index 4e967232..9d2b929e 100644 --- a/api/app/Models/Organisation.php +++ b/api/app/Models/Organisation.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; final class Organisation extends Model @@ -72,4 +73,19 @@ final class Organisation extends Model { return $this->hasMany(RegistrationFieldTemplate::class); } + + public function emailSettings(): HasOne + { + return $this->hasOne(OrganisationEmailSettings::class); + } + + public function emailTemplates(): HasMany + { + return $this->hasMany(OrganisationEmailTemplate::class); + } + + public function emailLogs(): HasMany + { + return $this->hasMany(EmailLog::class); + } } diff --git a/api/app/Models/OrganisationEmailSettings.php b/api/app/Models/OrganisationEmailSettings.php new file mode 100644 index 00000000..d335731e --- /dev/null +++ b/api/app/Models/OrganisationEmailSettings.php @@ -0,0 +1,31 @@ +belongsTo(Organisation::class); + } +} diff --git a/api/app/Models/OrganisationEmailTemplate.php b/api/app/Models/OrganisationEmailTemplate.php new file mode 100644 index 00000000..7c34472f --- /dev/null +++ b/api/app/Models/OrganisationEmailTemplate.php @@ -0,0 +1,38 @@ + EmailTemplateType::class, + ]; + } + + public function organisation(): BelongsTo + { + return $this->belongsTo(Organisation::class); + } +} diff --git a/api/app/Services/EmailChangeService.php b/api/app/Services/EmailChangeService.php index f4ae3479..4aa9ebd1 100644 --- a/api/app/Services/EmailChangeService.php +++ b/api/app/Services/EmailChangeService.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace App\Services; use App\Enums\EmailChangeStatus; +use App\Enums\EmailTemplateType; use App\Mail\EmailChangedConfirmationMail; -use App\Mail\VerifyEmailChangeMail; use App\Models\EmailChangeRequest; use App\Models\Person; use App\Models\User; @@ -17,6 +17,9 @@ use Illuminate\Validation\ValidationException; final class EmailChangeService { + public function __construct( + private readonly EmailService $emailService, + ) {} /** * Request an email change. Sends verification to the NEW email. */ @@ -52,13 +55,17 @@ final class EmailChangeService ]); // Send verification email to the NEW address - Mail::to($newEmail)->send(new VerifyEmailChangeMail( - user: $user, - newEmail: $newEmail, - token: $plainToken, - frontendUrl: $frontendUrl, - requestedBy: $requestedBy, - )); + $organisation = $user->organisations()->first(); + + $this->emailService->send( + type: EmailTemplateType::EMAIL_VERIFICATION, + recipientEmail: $newEmail, + recipientName: $user->first_name . ' ' . $user->last_name, + actionUrl: $frontendUrl . '/verify-email-change?token=' . $plainToken, + organisation: $organisation, + userId: $user->id, + triggeredByUserId: $requestedBy->id, + ); activity() ->causedBy($requestedBy) diff --git a/api/app/Services/EmailService.php b/api/app/Services/EmailService.php new file mode 100644 index 00000000..8e572847 --- /dev/null +++ b/api/app/Services/EmailService.php @@ -0,0 +1,169 @@ +resolveTemplate($type, $organisation); + $template = $this->substituteVariables($template, $variables); + $branding = $this->resolveBranding($organisation); + + $emailLog = EmailLog::create([ + 'organisation_id' => $organisation?->id, + 'event_id' => $eventId, + 'person_id' => $personId, + 'user_id' => $userId, + 'recipient_email' => $recipientEmail, + 'recipient_name' => $recipientName, + 'mailable_class' => TransactionalMail::class, + 'template_type' => $type->value, + 'subject' => $template['subject'], + 'status' => EmailLogStatus::QUEUED->value, + 'queued_at' => now(), + 'triggered_by_user_id' => $triggeredByUserId, + ]); + + SendTransactionalEmail::dispatch( + emailLogId: $emailLog->id, + type: $type, + recipientEmail: $recipientEmail, + recipientName: $recipientName, + template: $template, + branding: $branding, + actionUrl: $actionUrl, + ); + + return $emailLog; + } + + /** + * Resolve template: org override or system defaults. + * + * @return array{subject: string, heading: string|null, body_text: string, button_text: string|null, is_custom: bool} + */ + public function resolveTemplate(EmailTemplateType $type, ?Organisation $organisation = null): array + { + if ($organisation) { + $override = OrganisationEmailTemplate::where('organisation_id', $organisation->id) + ->where('type', $type->value) + ->first(); + + if ($override) { + return [ + 'subject' => $override->subject, + 'heading' => $override->heading, + 'body_text' => $override->body_text, + 'button_text' => $override->button_text, + 'is_custom' => true, + ]; + } + } + + $defaults = $type->defaults(); + $defaults['is_custom'] = false; + + return $defaults; + } + + /** + * Resolve organisation branding or system defaults. + * + * @return array{logo_url: string|null, primary_color: string, secondary_color: string, footer_text: string, reply_to_email: string|null, reply_to_name: string|null} + */ + public function resolveBranding(?Organisation $organisation = null): array + { + $defaults = [ + 'logo_url' => null, + 'primary_color' => '#6366F1', + 'secondary_color' => '#4F46E5', + 'footer_text' => '© ' . date('Y') . ' Crewli', + 'reply_to_email' => null, + 'reply_to_name' => null, + ]; + + if (! $organisation) { + return $defaults; + } + + $settings = OrganisationEmailSettings::where('organisation_id', $organisation->id)->first(); + + if (! $settings) { + $defaults['footer_text'] = '© ' . date('Y') . ' ' . $organisation->name; + + return $defaults; + } + + return [ + 'logo_url' => $settings->logo_url ?? $defaults['logo_url'], + 'primary_color' => $settings->primary_color ?? $defaults['primary_color'], + 'secondary_color' => $settings->secondary_color ?? $defaults['secondary_color'], + 'footer_text' => $settings->footer_text ?? ('© ' . date('Y') . ' ' . $organisation->name), + 'reply_to_email' => $settings->reply_to_email, + 'reply_to_name' => $settings->reply_to_name, + ]; + } + + /** + * Get all template types with their current content for an organisation. + * + * @return list + */ + public function getAllTemplates(?Organisation $organisation = null): array + { + $result = []; + + foreach (EmailTemplateType::cases() as $type) { + $template = $this->resolveTemplate($type, $organisation); + $template['type'] = $type->value; + $template['label'] = $type->label(); + $template['defaults'] = $type->defaults(); + $result[] = $template; + } + + return $result; + } + + /** + * Substitute {variable} placeholders in template text. + */ + private function substituteVariables(array $template, array $variables): array + { + foreach ($template as $key => $value) { + if (is_string($value)) { + foreach ($variables as $var => $replacement) { + $value = str_replace('{' . $var . '}', (string) $replacement, $value); + } + $template[$key] = $value; + } + } + + return $template; + } +} diff --git a/api/app/Services/InvitationService.php b/api/app/Services/InvitationService.php index 1d804a94..8f54d7c8 100644 --- a/api/app/Services/InvitationService.php +++ b/api/app/Services/InvitationService.php @@ -4,16 +4,19 @@ declare(strict_types=1); namespace App\Services; -use App\Mail\InvitationMail; +use App\Enums\EmailTemplateType; use App\Models\Organisation; use App\Models\User; use App\Models\UserInvitation; -use Illuminate\Support\Facades\Mail; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; final class InvitationService { + public function __construct( + private readonly EmailService $emailService, + ) {} + public function invite(Organisation $org, string $email, string $role, User $invitedBy): UserInvitation { $existingInvitation = UserInvitation::where('email', $email) @@ -50,7 +53,17 @@ final class InvitationService // Set transient plain token for use in the email URL $invitation->plainToken = $plainToken; - Mail::to($email)->queue(new InvitationMail($invitation)); + $this->emailService->send( + type: EmailTemplateType::INVITATION, + recipientEmail: $email, + recipientName: $email, + variables: [ + 'organisation_name' => $org->name, + ], + actionUrl: config('app.frontend_app_url') . '/invitations/' . $plainToken . '/accept', + organisation: $org, + triggeredByUserId: $invitedBy->id, + ); activity('invitation') ->performedOn($invitation) diff --git a/api/database/factories/EmailLogFactory.php b/api/database/factories/EmailLogFactory.php new file mode 100644 index 00000000..d092f32a --- /dev/null +++ b/api/database/factories/EmailLogFactory.php @@ -0,0 +1,55 @@ + */ +final class EmailLogFactory extends Factory +{ + protected $model = EmailLog::class; + + public function definition(): array + { + $type = fake()->randomElement(EmailTemplateType::cases()); + + return [ + 'organisation_id' => Organisation::factory(), + 'event_id' => null, + 'person_id' => null, + 'user_id' => null, + 'recipient_email' => fake()->safeEmail(), + 'recipient_name' => fake()->name(), + 'mailable_class' => TransactionalMail::class, + 'template_type' => $type->value, + 'subject' => fake()->sentence(), + 'status' => EmailLogStatus::QUEUED->value, + 'queued_at' => now(), + 'triggered_by_user_id' => null, + ]; + } + + public function sent(): static + { + return $this->state(fn () => [ + 'status' => EmailLogStatus::SENT->value, + 'sent_at' => now(), + ]); + } + + public function failed(): static + { + return $this->state(fn () => [ + 'status' => EmailLogStatus::FAILED->value, + 'failed_at' => now(), + 'error_message' => 'Connection refused', + ]); + } +} diff --git a/api/database/factories/OrganisationEmailSettingsFactory.php b/api/database/factories/OrganisationEmailSettingsFactory.php new file mode 100644 index 00000000..2ea1cda1 --- /dev/null +++ b/api/database/factories/OrganisationEmailSettingsFactory.php @@ -0,0 +1,28 @@ + */ +final class OrganisationEmailSettingsFactory extends Factory +{ + protected $model = OrganisationEmailSettings::class; + + public function definition(): array + { + return [ + 'organisation_id' => Organisation::factory(), + 'logo_url' => fake()->optional()->imageUrl(200, 60), + 'primary_color' => fake()->hexColor(), + 'secondary_color' => fake()->hexColor(), + 'footer_text' => '© ' . date('Y') . ' ' . fake()->company(), + 'reply_to_email' => fake()->optional()->safeEmail(), + 'reply_to_name' => fake()->optional()->name(), + ]; + } +} diff --git a/api/database/factories/OrganisationEmailTemplateFactory.php b/api/database/factories/OrganisationEmailTemplateFactory.php new file mode 100644 index 00000000..c65a7190 --- /dev/null +++ b/api/database/factories/OrganisationEmailTemplateFactory.php @@ -0,0 +1,35 @@ + */ +final class OrganisationEmailTemplateFactory extends Factory +{ + protected $model = OrganisationEmailTemplate::class; + + public function definition(): array + { + $type = fake()->randomElement(EmailTemplateType::cases()); + + return [ + 'organisation_id' => Organisation::factory(), + 'type' => $type->value, + 'subject' => fake()->sentence(), + 'heading' => fake()->optional()->sentence(4), + 'body_text' => fake()->paragraph(), + 'button_text' => fake()->optional()->words(3, true), + ]; + } + + public function forType(EmailTemplateType $type): static + { + return $this->state(fn () => ['type' => $type->value]); + } +} diff --git a/api/database/migrations/2026_04_15_100000_create_organisation_email_settings_table.php b/api/database/migrations/2026_04_15_100000_create_organisation_email_settings_table.php new file mode 100644 index 00000000..0d98b038 --- /dev/null +++ b/api/database/migrations/2026_04_15_100000_create_organisation_email_settings_table.php @@ -0,0 +1,34 @@ +ulid('id')->primary(); + $table->ulid('organisation_id'); + $table->string('logo_url', 500)->nullable(); + $table->string('primary_color', 7)->default('#6366F1'); + $table->string('secondary_color', 7)->default('#4F46E5'); + $table->string('footer_text', 200)->nullable(); + $table->string('reply_to_email')->nullable(); + $table->string('reply_to_name', 100)->nullable(); + $table->timestamps(); + + $table->foreign('organisation_id')->references('id')->on('organisations') + ->cascadeOnDelete(); + $table->unique('organisation_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('organisation_email_settings'); + } +}; diff --git a/api/database/migrations/2026_04_15_100001_create_organisation_email_templates_table.php b/api/database/migrations/2026_04_15_100001_create_organisation_email_templates_table.php new file mode 100644 index 00000000..87c6f36f --- /dev/null +++ b/api/database/migrations/2026_04_15_100001_create_organisation_email_templates_table.php @@ -0,0 +1,33 @@ +ulid('id')->primary(); + $table->ulid('organisation_id'); + $table->string('type', 50); + $table->string('subject', 200); + $table->string('heading', 200)->nullable(); + $table->text('body_text'); + $table->string('button_text', 100)->nullable(); + $table->timestamps(); + + $table->foreign('organisation_id')->references('id')->on('organisations') + ->cascadeOnDelete(); + $table->unique(['organisation_id', 'type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('organisation_email_templates'); + } +}; diff --git a/api/database/migrations/2026_04_15_100002_create_email_logs_table.php b/api/database/migrations/2026_04_15_100002_create_email_logs_table.php new file mode 100644 index 00000000..1ac0ab6f --- /dev/null +++ b/api/database/migrations/2026_04_15_100002_create_email_logs_table.php @@ -0,0 +1,49 @@ +ulid('id')->primary(); + $table->ulid('organisation_id')->nullable(); + $table->ulid('event_id')->nullable(); + $table->ulid('person_id')->nullable(); + $table->ulid('user_id')->nullable(); + $table->string('recipient_email'); + $table->string('recipient_name')->nullable(); + $table->string('mailable_class'); + $table->string('template_type', 50); + $table->string('subject'); + $table->string('status', 20)->default('queued'); + $table->text('error_message')->nullable(); + $table->timestamp('queued_at'); + $table->timestamp('sent_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->ulid('triggered_by_user_id')->nullable(); + $table->timestamps(); + + $table->foreign('organisation_id')->references('id')->on('organisations') + ->nullOnDelete(); + $table->foreign('event_id')->references('id')->on('events') + ->nullOnDelete(); + + $table->index(['organisation_id', 'created_at']); + $table->index(['recipient_email', 'created_at']); + $table->index(['template_type', 'status']); + $table->index(['event_id']); + $table->index(['person_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('email_logs'); + } +}; diff --git a/api/resources/views/emails/transactional.blade.php b/api/resources/views/emails/transactional.blade.php new file mode 100644 index 00000000..39256cfa --- /dev/null +++ b/api/resources/views/emails/transactional.blade.php @@ -0,0 +1,81 @@ + + + + + + {{ $heading ?? '' }} + + + + + + + +
+ + {{-- Accent line --}} + + + + + {{-- Header: logo or Crewli text --}} + + + + + {{-- Body --}} + + + + + {{-- Footer --}} + + + +
+ @if($logoUrl) + Logo + @else +

Crewli

+ @endif +
+ {{-- Heading --}} + @if($heading) +

+ {{ $heading }} +

+ @endif + + {{-- Body text --}} +
+ {!! nl2br(e($bodyText)) !!} +
+ + {{-- CTA button --}} + @if($buttonText && $actionUrl) + + @endif +
+ @if($footerText) +

+ {{ $footerText }} +

+ @endif + +

+ Powered by Crewli +

+
+
+ + diff --git a/api/resources/views/emails/transactional_text.blade.php b/api/resources/views/emails/transactional_text.blade.php new file mode 100644 index 00000000..d8f79c4e --- /dev/null +++ b/api/resources/views/emails/transactional_text.blade.php @@ -0,0 +1,12 @@ +@if($heading) +{{ $heading }} + +@endif +{{ $bodyText }} +@if($buttonText && $actionUrl) + +{{ $buttonText }}: {{ $actionUrl }} +@endif + +--- +{{ $footerText ?? 'Crewli' }} diff --git a/api/routes/api.php b/api/routes/api.php index 26fa992c..ac60bc79 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -4,6 +4,7 @@ declare(strict_types=1); use App\Http\Controllers\Api\V1\CheckEmailController; use App\Http\Controllers\Api\V1\CompanyController; +use App\Http\Controllers\Api\V1\EmailLogController; use App\Http\Controllers\Api\V1\CrowdListController; use App\Http\Controllers\Api\V1\CrowdTypeController; use App\Http\Controllers\Api\V1\EventController; @@ -15,6 +16,8 @@ use App\Http\Controllers\Api\V1\LogoutController; use App\Http\Controllers\Api\V1\MeController; use App\Http\Controllers\Api\V1\MemberController; use App\Http\Controllers\Api\V1\OrganisationController; +use App\Http\Controllers\Api\V1\OrganisationEmailSettingsController; +use App\Http\Controllers\Api\V1\OrganisationEmailTemplateController; use App\Http\Controllers\Api\V1\PersonController; use App\Http\Controllers\Api\V1\PersonFieldValueController; use App\Http\Controllers\Api\V1\PersonIdentityMatchController; @@ -163,6 +166,20 @@ Route::middleware('auth:sanctum')->group(function () { return response()->json(['data' => $categories]); }); + // Email settings & templates + Route::get('email-settings', [OrganisationEmailSettingsController::class, 'show']); + Route::put('email-settings', [OrganisationEmailSettingsController::class, 'update']); + + Route::get('email-templates', [OrganisationEmailTemplateController::class, 'index']); + Route::get('email-templates/{type}', [OrganisationEmailTemplateController::class, 'show']); + Route::put('email-templates/{type}', [OrganisationEmailTemplateController::class, 'update']); + Route::delete('email-templates/{type}', [OrganisationEmailTemplateController::class, 'destroy']); + Route::post('email-templates/{type}/preview', [OrganisationEmailTemplateController::class, 'preview']); + Route::post('email-templates/{type}/send-test', [OrganisationEmailTemplateController::class, 'sendTest']); + + // Email logs (read-only) + Route::get('email-logs', [EmailLogController::class, 'index']); + // Person tags (organisation settings) Route::apiResource('person-tags', PersonTagController::class) ->except(['show']); diff --git a/api/tests/Feature/Api/V1/EmailChangeTest.php b/api/tests/Feature/Api/V1/EmailChangeTest.php index 01dd1ab2..9c6b272a 100644 --- a/api/tests/Feature/Api/V1/EmailChangeTest.php +++ b/api/tests/Feature/Api/V1/EmailChangeTest.php @@ -5,13 +5,15 @@ declare(strict_types=1); namespace Tests\Feature\Api\V1; use App\Enums\EmailChangeStatus; +use App\Enums\EmailTemplateType; +use App\Jobs\SendTransactionalEmail; use App\Mail\EmailChangedConfirmationMail; -use App\Mail\VerifyEmailChangeMail; use App\Models\EmailChangeRequest; use App\Models\Organisation; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Queue; use Tests\TestCase; class EmailChangeTest extends TestCase @@ -22,7 +24,7 @@ class EmailChangeTest extends TestCase public function test_user_can_request_email_change(): void { - Mail::fake(); + Queue::fake(); $user = User::factory()->create([ 'email' => 'oud@voorbeeld.nl', @@ -44,8 +46,9 @@ class EmailChangeTest extends TestCase 'status' => 'pending', ]); - Mail::assertQueued(VerifyEmailChangeMail::class, function ($mail) { - return $mail->hasTo('nieuw@voorbeeld.nl'); + Queue::assertPushed(SendTransactionalEmail::class, function ($job) { + return $job->recipientEmail === 'nieuw@voorbeeld.nl' + && $job->type === EmailTemplateType::EMAIL_VERIFICATION; }); } @@ -249,7 +252,7 @@ class EmailChangeTest extends TestCase public function test_admin_can_change_member_email(): void { - Mail::fake(); + Queue::fake(); $organisation = Organisation::factory()->create(); $admin = User::factory()->create(); @@ -265,8 +268,9 @@ class EmailChangeTest extends TestCase $response->assertOk(); - Mail::assertQueued(VerifyEmailChangeMail::class, function ($mail) { - return $mail->hasTo('nieuw-lid@voorbeeld.nl'); + Queue::assertPushed(SendTransactionalEmail::class, function ($job) { + return $job->recipientEmail === 'nieuw-lid@voorbeeld.nl' + && $job->type === EmailTemplateType::EMAIL_VERIFICATION; }); } diff --git a/api/tests/Feature/Api/V1/PasswordResetTest.php b/api/tests/Feature/Api/V1/PasswordResetTest.php index 5fbbc420..3791287a 100644 --- a/api/tests/Feature/Api/V1/PasswordResetTest.php +++ b/api/tests/Feature/Api/V1/PasswordResetTest.php @@ -4,12 +4,13 @@ declare(strict_types=1); namespace Tests\Feature\Api\V1; +use App\Enums\EmailTemplateType; +use App\Jobs\SendTransactionalEmail; use App\Models\User; -use App\Notifications\ResetPasswordNotification; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Password; +use Illuminate\Support\Facades\Queue; use Tests\TestCase; class PasswordResetTest extends TestCase @@ -20,7 +21,7 @@ class PasswordResetTest extends TestCase public function test_forgot_password_returns_success_for_existing_email(): void { - Notification::fake(); + Queue::fake(); $user = User::factory()->create(['email' => 'jan@voorbeeld.nl']); @@ -31,11 +32,16 @@ class PasswordResetTest extends TestCase $response->assertOk(); - Notification::assertSentTo($user, ResetPasswordNotification::class); + Queue::assertPushed(SendTransactionalEmail::class, function ($job) { + return $job->recipientEmail === 'jan@voorbeeld.nl' + && $job->type === EmailTemplateType::PASSWORD_RESET; + }); } public function test_forgot_password_returns_same_success_for_nonexisting_email(): void { + Queue::fake(); + $response = $this->postJson('/api/v1/auth/forgot-password', [ 'email' => 'onbekend@voorbeeld.nl', 'app' => 'app', diff --git a/api/tests/Feature/Api/V1/PersonApprovalEmailTest.php b/api/tests/Feature/Api/V1/PersonApprovalEmailTest.php index 21246de3..44cb75e3 100644 --- a/api/tests/Feature/Api/V1/PersonApprovalEmailTest.php +++ b/api/tests/Feature/Api/V1/PersonApprovalEmailTest.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Tests\Feature\Api\V1; -use App\Mail\RegistrationApprovedMail; -use App\Mail\RegistrationRejectedMail; +use App\Jobs\SendTransactionalEmail; +use App\Enums\EmailTemplateType; use App\Models\CrowdType; use App\Models\Event; use App\Models\Organisation; @@ -13,7 +13,7 @@ use App\Models\Person; use App\Models\User; use Database\Seeders\RoleSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Queue; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -46,7 +46,7 @@ class PersonApprovalEmailTest extends TestCase public function test_approving_person_sends_approved_email(): void { - Mail::fake(); + Queue::fake(); $person = Person::factory()->create([ 'event_id' => $this->event->id, @@ -61,14 +61,20 @@ class PersonApprovalEmailTest extends TestCase $response->assertOk(); - Mail::assertQueued(RegistrationApprovedMail::class, function ($mail) { - return $mail->hasTo('volunteer@test.nl'); + Queue::assertPushed(SendTransactionalEmail::class, function ($job) { + return $job->recipientEmail === 'volunteer@test.nl' + && $job->type === EmailTemplateType::REGISTRATION_APPROVED; }); + + $this->assertDatabaseHas('email_logs', [ + 'recipient_email' => 'volunteer@test.nl', + 'template_type' => 'registration_approved', + ]); } public function test_rejecting_person_sends_rejected_email_with_reason(): void { - Mail::fake(); + Queue::fake(); $person = Person::factory()->create([ 'event_id' => $this->event->id, @@ -90,15 +96,15 @@ class PersonApprovalEmailTest extends TestCase 'status' => 'rejected', ]); - Mail::assertQueued(RegistrationRejectedMail::class, function ($mail) { - return $mail->hasTo('volunteer@test.nl') - && $mail->reason === 'Geen beschikbaarheid op de juiste momenten.'; + Queue::assertPushed(SendTransactionalEmail::class, function ($job) { + return $job->recipientEmail === 'volunteer@test.nl' + && $job->type === EmailTemplateType::REGISTRATION_REJECTED; }); } public function test_rejecting_person_sends_rejected_email_without_reason(): void { - Mail::fake(); + Queue::fake(); $person = Person::factory()->create([ 'event_id' => $this->event->id, @@ -113,9 +119,9 @@ class PersonApprovalEmailTest extends TestCase $response->assertOk(); - Mail::assertQueued(RegistrationRejectedMail::class, function ($mail) { - return $mail->hasTo('volunteer@test.nl') - && $mail->reason === null; + Queue::assertPushed(SendTransactionalEmail::class, function ($job) { + return $job->recipientEmail === 'volunteer@test.nl' + && $job->type === EmailTemplateType::REGISTRATION_REJECTED; }); } diff --git a/api/tests/Feature/Email/EmailLogControllerTest.php b/api/tests/Feature/Email/EmailLogControllerTest.php new file mode 100644 index 00000000..5133bd42 --- /dev/null +++ b/api/tests/Feature/Email/EmailLogControllerTest.php @@ -0,0 +1,162 @@ +seed(RoleSeeder::class); + + $this->organisation = Organisation::factory()->create(); + $this->otherOrganisation = Organisation::factory()->create(); + + $this->orgAdmin = User::factory()->create(); + $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + + $this->outsider = User::factory()->create(); + $this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']); + } + + public function test_index_returns_paginated_logs(): void + { + EmailLog::factory()->count(3)->create([ + 'organisation_id' => $this->organisation->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/email-logs"); + + $response->assertOk(); + $this->assertCount(3, $response->json('data.data')); + } + + public function test_filter_by_status(): void + { + EmailLog::factory()->sent()->create([ + 'organisation_id' => $this->organisation->id, + ]); + EmailLog::factory()->failed()->create([ + 'organisation_id' => $this->organisation->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/email-logs?status=sent"); + + $response->assertOk(); + $this->assertCount(1, $response->json('data.data')); + $this->assertEquals('sent', $response->json('data.data.0.status')); + } + + public function test_filter_by_template_type(): void + { + EmailLog::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'template_type' => EmailTemplateType::INVITATION->value, + ]); + EmailLog::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'template_type' => EmailTemplateType::PASSWORD_RESET->value, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/email-logs?template_type=invitation"); + + $response->assertOk(); + $this->assertCount(1, $response->json('data.data')); + $this->assertEquals('invitation', $response->json('data.data.0.template_type')); + } + + public function test_filter_by_date_range(): void + { + EmailLog::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'created_at' => '2026-04-10 12:00:00', + ]); + EmailLog::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'created_at' => '2026-04-15 12:00:00', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/email-logs?from=2026-04-14&to=2026-04-16"); + + $response->assertOk(); + $this->assertCount(1, $response->json('data.data')); + } + + public function test_search_by_recipient_email(): void + { + EmailLog::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'recipient_email' => 'needle@example.com', + ]); + EmailLog::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'recipient_email' => 'other@example.com', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/email-logs?search=needle"); + + $response->assertOk(); + $this->assertCount(1, $response->json('data.data')); + $this->assertEquals('needle@example.com', $response->json('data.data.0.recipient_email')); + } + + public function test_denied_for_non_org_admin(): void + { + Sanctum::actingAs($this->outsider); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/email-logs"); + + $response->assertForbidden(); + } + + public function test_cannot_see_other_org_logs(): void + { + EmailLog::factory()->count(2)->create([ + 'organisation_id' => $this->otherOrganisation->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/email-logs"); + + $response->assertOk(); + $this->assertCount(0, $response->json('data.data')); + } + + public function test_unauthenticated_returns_401(): void + { + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/email-logs"); + + $response->assertUnauthorized(); + } +} diff --git a/api/tests/Feature/Email/EmailServiceTest.php b/api/tests/Feature/Email/EmailServiceTest.php new file mode 100644 index 00000000..430f165f --- /dev/null +++ b/api/tests/Feature/Email/EmailServiceTest.php @@ -0,0 +1,173 @@ +service = app(EmailService::class); + $this->organisation = Organisation::factory()->create(['name' => 'Test Org']); + } + + public function test_send_creates_email_log_with_queued_status(): void + { + Queue::fake(); + + $log = $this->service->send( + type: EmailTemplateType::INVITATION, + recipientEmail: 'test@example.com', + recipientName: 'Test User', + variables: ['organisation_name' => 'Test Org'], + organisation: $this->organisation, + ); + + $this->assertDatabaseHas('email_logs', [ + 'id' => $log->id, + 'recipient_email' => 'test@example.com', + 'template_type' => 'invitation', + 'status' => 'queued', + 'organisation_id' => $this->organisation->id, + ]); + } + + public function test_send_dispatches_queued_job(): void + { + Queue::fake(); + + $this->service->send( + type: EmailTemplateType::INVITATION, + recipientEmail: 'test@example.com', + recipientName: 'Test User', + variables: ['organisation_name' => 'Test Org'], + organisation: $this->organisation, + ); + + Queue::assertPushed(SendTransactionalEmail::class, function ($job) { + return $job->recipientEmail === 'test@example.com' + && $job->type === EmailTemplateType::INVITATION; + }); + } + + public function test_resolve_template_returns_defaults_without_org(): void + { + $template = $this->service->resolveTemplate(EmailTemplateType::INVITATION); + + $this->assertFalse($template['is_custom']); + $this->assertStringContainsString('{organisation_name}', $template['subject']); + $this->assertEquals('Uitnodiging accepteren', $template['button_text']); + } + + public function test_resolve_template_returns_org_override_when_exists(): void + { + OrganisationEmailTemplate::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'type' => EmailTemplateType::INVITATION->value, + 'subject' => 'Aangepaste uitnodiging', + 'heading' => 'Welkom!', + 'body_text' => 'Aangepaste tekst', + 'button_text' => 'Klik hier', + ]); + + $template = $this->service->resolveTemplate(EmailTemplateType::INVITATION, $this->organisation); + + $this->assertTrue($template['is_custom']); + $this->assertEquals('Aangepaste uitnodiging', $template['subject']); + $this->assertEquals('Klik hier', $template['button_text']); + } + + public function test_resolve_template_falls_back_to_defaults_without_override(): void + { + $template = $this->service->resolveTemplate(EmailTemplateType::PASSWORD_RESET, $this->organisation); + + $this->assertFalse($template['is_custom']); + $this->assertEquals('Wachtwoord resetten', $template['subject']); + } + + public function test_variable_substitution_replaces_placeholders(): void + { + Queue::fake(); + + $log = $this->service->send( + type: EmailTemplateType::INVITATION, + recipientEmail: 'test@example.com', + recipientName: 'Test User', + variables: ['organisation_name' => 'Feestfabriek'], + organisation: $this->organisation, + ); + + $this->assertDatabaseHas('email_logs', [ + 'id' => $log->id, + 'subject' => 'Je bent uitgenodigd voor Feestfabriek', + ]); + } + + public function test_branding_uses_org_settings_when_available(): void + { + OrganisationEmailSettings::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'primary_color' => '#FF0000', + 'secondary_color' => '#00FF00', + 'footer_text' => 'Custom Footer', + 'reply_to_email' => 'reply@example.com', + 'reply_to_name' => 'Reply Name', + ]); + + $branding = $this->service->resolveBranding($this->organisation); + + $this->assertEquals('#FF0000', $branding['primary_color']); + $this->assertEquals('#00FF00', $branding['secondary_color']); + $this->assertEquals('Custom Footer', $branding['footer_text']); + $this->assertEquals('reply@example.com', $branding['reply_to_email']); + $this->assertEquals('Reply Name', $branding['reply_to_name']); + } + + public function test_branding_falls_back_to_defaults(): void + { + $branding = $this->service->resolveBranding($this->organisation); + + $this->assertEquals('#6366F1', $branding['primary_color']); + $this->assertEquals('#4F46E5', $branding['secondary_color']); + $this->assertStringContainsString('Test Org', $branding['footer_text']); + $this->assertNull($branding['reply_to_email']); + } + + public function test_branding_returns_system_defaults_without_org(): void + { + $branding = $this->service->resolveBranding(null); + + $this->assertEquals('#6366F1', $branding['primary_color']); + $this->assertStringContainsString('Crewli', $branding['footer_text']); + } + + public function test_get_all_templates_returns_all_types(): void + { + $templates = $this->service->getAllTemplates($this->organisation); + + $this->assertCount(count(EmailTemplateType::cases()), $templates); + + $types = array_column($templates, 'type'); + foreach (EmailTemplateType::cases() as $case) { + $this->assertContains($case->value, $types); + } + } +} diff --git a/api/tests/Feature/Email/EmailSettingsAndTemplateControllerTest.php b/api/tests/Feature/Email/EmailSettingsAndTemplateControllerTest.php new file mode 100644 index 00000000..e6acbe3d --- /dev/null +++ b/api/tests/Feature/Email/EmailSettingsAndTemplateControllerTest.php @@ -0,0 +1,235 @@ +seed(RoleSeeder::class); + + $this->organisation = Organisation::factory()->create(['name' => 'Test Org']); + $this->otherOrganisation = Organisation::factory()->create(); + + $this->orgAdmin = User::factory()->create(); + $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + + $this->outsider = User::factory()->create(); + $this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']); + } + + // ── Email Settings ── + + public function test_show_returns_defaults_when_no_settings(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/email-settings"); + + $response->assertOk(); + $response->assertJsonPath('data.primary_color', '#6366F1'); + } + + public function test_show_returns_custom_settings(): void + { + OrganisationEmailSettings::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'primary_color' => '#FF0000', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/email-settings"); + + $response->assertOk(); + $response->assertJsonPath('data.primary_color', '#FF0000'); + } + + public function test_update_creates_settings_if_not_exists(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/email-settings", [ + 'primary_color' => '#00FF00', + 'footer_text' => 'Custom Footer', + ]); + + $response->assertOk(); + + $this->assertDatabaseHas('organisation_email_settings', [ + 'organisation_id' => $this->organisation->id, + 'primary_color' => '#00FF00', + 'footer_text' => 'Custom Footer', + ]); + } + + public function test_update_modifies_existing_settings(): void + { + OrganisationEmailSettings::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'primary_color' => '#FF0000', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/email-settings", [ + 'primary_color' => '#00FF00', + ]); + + $response->assertOk(); + $response->assertJsonPath('data.primary_color', '#00FF00'); + } + + public function test_update_validates_hex_color(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/email-settings", [ + 'primary_color' => 'invalid', + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['primary_color']); + } + + public function test_settings_denied_for_non_org_admin(): void + { + Sanctum::actingAs($this->outsider); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/email-settings"); + + $response->assertForbidden(); + } + + // ── Email Templates ── + + public function test_index_returns_all_template_types_with_content(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/email-templates"); + + $response->assertOk(); + $this->assertCount(count(EmailTemplateType::cases()), $response->json('data')); + } + + public function test_show_returns_template_with_defaults(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/email-templates/invitation"); + + $response->assertOk(); + $response->assertJsonPath('data.type', 'invitation'); + $response->assertJsonPath('data.is_custom', false); + $this->assertArrayHasKey('defaults', $response->json('data')); + } + + public function test_update_creates_custom_override(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/email-templates/invitation", [ + 'subject' => 'Aangepaste uitnodiging', + 'body_text' => 'Aangepaste tekst voor de uitnodiging.', + ]); + + $response->assertOk(); + $response->assertJsonPath('data.is_custom', true); + $response->assertJsonPath('data.subject', 'Aangepaste uitnodiging'); + + $this->assertDatabaseHas('organisation_email_templates', [ + 'organisation_id' => $this->organisation->id, + 'type' => 'invitation', + 'subject' => 'Aangepaste uitnodiging', + ]); + } + + public function test_destroy_resets_to_default(): void + { + OrganisationEmailTemplate::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'type' => EmailTemplateType::INVITATION->value, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/email-templates/invitation"); + + $response->assertOk(); + + $this->assertDatabaseMissing('organisation_email_templates', [ + 'organisation_id' => $this->organisation->id, + 'type' => 'invitation', + ]); + } + + public function test_preview_returns_rendered_html(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/email-templates/invitation/preview"); + + $response->assertOk(); + $this->assertArrayHasKey('html', $response->json('data')); + $this->assertStringContainsString('', $response->json('data.html')); + } + + public function test_send_test_queues_email(): void + { + Queue::fake(); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/email-templates/invitation/send-test", [ + 'email' => 'test@example.com', + ]); + + $response->assertOk(); + + $this->assertDatabaseHas('email_logs', [ + 'organisation_id' => $this->organisation->id, + 'recipient_email' => 'test@example.com', + 'template_type' => 'invitation', + ]); + } + + public function test_templates_denied_for_non_org_admin(): void + { + Sanctum::actingAs($this->outsider); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/email-templates"); + + $response->assertForbidden(); + } + + public function test_show_returns_404_for_invalid_type(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/email-templates/nonexistent"); + + $response->assertNotFound(); + } +} diff --git a/api/tests/Feature/Email/SendTransactionalEmailJobTest.php b/api/tests/Feature/Email/SendTransactionalEmailJobTest.php new file mode 100644 index 00000000..73575f47 --- /dev/null +++ b/api/tests/Feature/Email/SendTransactionalEmailJobTest.php @@ -0,0 +1,178 @@ +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'); + }); + } +} diff --git a/api/tests/Feature/Invitation/InvitationTest.php b/api/tests/Feature/Invitation/InvitationTest.php index a5bd30be..dc187c36 100644 --- a/api/tests/Feature/Invitation/InvitationTest.php +++ b/api/tests/Feature/Invitation/InvitationTest.php @@ -4,12 +4,15 @@ declare(strict_types=1); namespace Tests\Feature\Invitation; +use App\Enums\EmailTemplateType; +use App\Jobs\SendTransactionalEmail; use App\Models\Organisation; use App\Models\User; use App\Models\UserInvitation; use Database\Seeders\RoleSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Queue; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -34,7 +37,7 @@ class InvitationTest extends TestCase public function test_org_admin_can_invite_user(): void { - Mail::fake(); + Queue::fake(); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/organisations/{$this->org->id}/invite", [ @@ -48,7 +51,10 @@ class InvitationTest extends TestCase 'organisation_id' => $this->org->id, 'status' => 'pending', ]); - Mail::assertQueued(\App\Mail\InvitationMail::class); + Queue::assertPushed(SendTransactionalEmail::class, function ($job) { + return $job->recipientEmail === 'newuser@test.nl' + && $job->type === EmailTemplateType::INVITATION; + }); } public function test_org_member_cannot_invite_user(): void diff --git a/api/tests/Feature/Security/AuthenticationSecurityTest.php b/api/tests/Feature/Security/AuthenticationSecurityTest.php index ec693e3d..335b05d6 100644 --- a/api/tests/Feature/Security/AuthenticationSecurityTest.php +++ b/api/tests/Feature/Security/AuthenticationSecurityTest.php @@ -9,6 +9,7 @@ use Database\Seeders\RoleSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Password; +use Illuminate\Support\Facades\Queue; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -98,6 +99,8 @@ final class AuthenticationSecurityTest extends TestCase public function test_password_reset_returns_success_for_unknown_email(): void { + Queue::fake(); + $response = $this->postJson('/api/v1/auth/forgot-password', [ 'email' => 'nonexistent@example.com', 'app' => 'app', @@ -109,6 +112,8 @@ final class AuthenticationSecurityTest extends TestCase public function test_password_reset_returns_success_for_known_email(): void { + Queue::fake(); + $user = User::factory()->create(); $response = $this->postJson('/api/v1/auth/forgot-password', [ diff --git a/dev-docs/API.md b/dev-docs/API.md index b5ad6c0c..9390617b 100644 --- a/dev-docs/API.md +++ b/dev-docs/API.md @@ -922,3 +922,121 @@ Base path: `/api/v1/admin/` - Impersonation token has name `impersonation-by-{admin_id}` - Admin ID is cached for 4 hours at key `impersonation:{token_id}` - Activity log records both start (`admin.impersonation.started`) and stop (`admin.impersonation.stopped`) + +## Email Settings (org admin) + +- `GET /organisations/{org}/email-settings` — get current email branding settings (returns defaults if none configured) +- `PUT /organisations/{org}/email-settings` — create or update email branding settings + +### PUT body + +```json +{ + "logo_url": "https://example.com/logo.png", + "primary_color": "#FF5500", + "secondary_color": "#CC4400", + "footer_text": "© 2026 Stichting Feestfabriek", + "reply_to_email": "info@festival.nl", + "reply_to_name": "Festival Team" +} +``` + +All fields are optional/nullable. Colors must match `#[0-9A-Fa-f]{6}`. + +## Email Templates (org admin) + +- `GET /organisations/{org}/email-templates` — list all template types with current content (custom or default) +- `GET /organisations/{org}/email-templates/{type}` — get single template with both custom content and defaults +- `PUT /organisations/{org}/email-templates/{type}` — create or update custom template for this type +- `DELETE /organisations/{org}/email-templates/{type}` — reset to system default (deletes custom override) +- `POST /organisations/{org}/email-templates/{type}/preview` — render email HTML with sample data +- `POST /organisations/{org}/email-templates/{type}/send-test` — send test email. Body: `{ "email": "test@example.com" }` + +### Template types + +`invitation`, `password_reset`, `email_verification`, `registration_approved`, `registration_rejected`, `shift_assignment` + +### PUT body + +```json +{ + "subject": "Aangepaste uitnodiging voor {organisation_name}", + "heading": "Welkom!", + "body_text": "Aangepaste tekst met {organisation_name} variabelen.", + "button_text": "Klik hier" +} +``` + +### GET response (index) + +Returns array of all template types with resolved content: + +```json +{ + "data": [ + { + "type": "invitation", + "label": "Uitnodiging", + "is_custom": false, + "subject": "Je bent uitgenodigd voor {organisation_name}", + "heading": "Welkom bij {organisation_name}!", + "body_text": "...", + "button_text": "Uitnodiging accepteren", + "defaults": { "subject": "...", "heading": "...", "body_text": "...", "button_text": "..." } + } + ] +} +``` + +### Template variables + +- `{organisation_name}` — available in all templates +- `{event_name}` — available in registration and shift templates +- `{shift_title}`, `{shift_date}`, `{shift_start}`, `{shift_end}`, `{section_name}` — shift assignment only + +## Email Logs (org admin, read-only) + +- `GET /organisations/{org}/email-logs` — paginated email log + +### Query parameters + +| Param | Type | Description | +| --------------- | ------ | ------------------------------------ | +| `search` | string | Search by recipient_email | +| `status` | string | Filter: `queued\|sent\|failed` | +| `template_type` | string | Filter by EmailTemplateType | +| `event_id` | ULID | Filter by event | +| `person_id` | ULID | Filter by person | +| `from` | date | Start of date range | +| `to` | date | End of date range | +| `per_page` | int | Results per page (default 15) | + +### Response + +```json +{ + "data": { + "data": [ + { + "id": "01JXYZ...", + "recipient_email": "volunteer@test.nl", + "recipient_name": "Jan Janssen", + "template_type": "registration_approved", + "template_label": "Registratie goedgekeurd", + "subject": "Je registratie voor Festival X is goedgekeurd!", + "status": "sent", + "error_message": null, + "queued_at": "2026-04-15T12:00:00+00:00", + "sent_at": "2026-04-15T12:00:05+00:00", + "failed_at": null, + "triggered_by": { "id": "01JXYZ...", "name": "Admin User" }, + "event_id": "01JXYZ...", + "person_id": "01JXYZ...", + "created_at": "2026-04-15T12:00:00+00:00" + } + ], + "links": { "...pagination..." }, + "meta": { "...pagination..." } + } +} +``` diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index 7a3c8b7f..15db0651 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -1760,3 +1760,76 @@ Exception: when `shifts.allow_overlap = true`, the **application layer** skips t - Any role explicitly marked as overlap-allowed in the planning document The DB constraint remains as a safety net for all other cases. + +--- + +## 3.5.10 Email Infrastructure + +### `organisation_email_settings` + +Per-organisation email branding configuration. One-to-one with organisations. + +| Column | Type | Notes | +| ------------------ | ------------------ | ---------------------------------------- | +| `id` | ULID | PK | +| `organisation_id` | ULID FK | → organisations, UNIQUE, CASCADE DELETE | +| `logo_url` | string(500) nullable | Logo URL for email header | +| `primary_color` | string(7) | Hex color, default `#6366F1` | +| `secondary_color` | string(7) | Hex color, default `#4F46E5` | +| `footer_text` | string(200) nullable | Custom footer text | +| `reply_to_email` | string nullable | Override reply-to per org | +| `reply_to_name` | string(100) nullable | Reply-to display name | + +**Relations:** `belongsTo` organisation +**Soft delete:** no + +--- + +### `organisation_email_templates` + +Per-organisation, per-type email text overrides. When no override exists, system defaults from `EmailTemplateType` enum are used. + +| Column | Type | Notes | +| ------------------ | ------------------ | --------------------------------------------------- | +| `id` | ULID | PK | +| `organisation_id` | ULID FK | → organisations, CASCADE DELETE | +| `type` | string(50) | `EmailTemplateType` enum value | +| `subject` | string(200) | Custom subject line | +| `heading` | string(200) nullable | Custom heading in email body | +| `body_text` | text | Custom body text (supports `{variable}` placeholders) | +| `button_text` | string(100) nullable | Custom CTA button label | + +**Unique constraint:** `UNIQUE(organisation_id, type)` +**Relations:** `belongsTo` organisation +**Soft delete:** no + +**Template types:** `invitation`, `password_reset`, `email_verification`, `registration_approved`, `registration_rejected`, `shift_assignment` + +--- + +### `email_logs` + +Immutable audit record of every email sent. No soft deletes. + +| Column | Type | Notes | +| ---------------------- | ------------------ | ----------------------------------------------- | +| `id` | ULID | PK | +| `organisation_id` | ULID FK nullable | → organisations, NULL ON DELETE | +| `event_id` | ULID FK nullable | → events, NULL ON DELETE | +| `person_id` | ULID nullable | Person context if applicable | +| `user_id` | ULID nullable | User context if applicable | +| `recipient_email` | string | | +| `recipient_name` | string nullable | | +| `mailable_class` | string | e.g. `App\Mail\TransactionalMail` | +| `template_type` | string(50) | `EmailTemplateType` enum value | +| `subject` | string | Resolved subject (after variable substitution) | +| `status` | string(20) | `queued\|sent\|failed` | +| `error_message` | text nullable | Failure reason | +| `queued_at` | timestamp | | +| `sent_at` | timestamp nullable | | +| `failed_at` | timestamp nullable | | +| `triggered_by_user_id` | ULID nullable | Who triggered the email | + +**Indexes:** `(organisation_id, created_at)`, `(recipient_email, created_at)`, `(template_type, status)`, `(event_id)`, `(person_id)` +**Relations:** `belongsTo` organisation (nullable), event (nullable), person (nullable), user (nullable), triggeredBy → user +**Soft delete:** no — immutable audit table