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

@@ -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

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\EmailLogStatus;
use App\Enums\EmailTemplateType;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\V1\EmailLogResource;
use App\Models\EmailLog;
use App\Models\Organisation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
final class EmailLogController extends Controller
{
public function index(Request $request, Organisation $organisation): JsonResponse
{
Gate::authorize('update', $organisation);
$query = EmailLog::where('organisation_id', $organisation->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));
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\UpdateEmailSettingsRequest;
use App\Http\Resources\Api\V1\EmailSettingsResource;
use App\Models\Organisation;
use App\Models\OrganisationEmailSettings;
use App\Services\EmailService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
final class OrganisationEmailSettingsController extends Controller
{
public function __construct(
private readonly EmailService $emailService,
) {}
public function show(Organisation $organisation): JsonResponse
{
Gate::authorize('update', $organisation);
$settings = $organisation->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()));
}
}

View File

@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\EmailTemplateType;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\UpdateEmailTemplateRequest;
use App\Models\Organisation;
use App\Models\OrganisationEmailTemplate;
use App\Services\EmailService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\View;
final class OrganisationEmailTemplateController extends Controller
{
public function __construct(
private readonly EmailService $emailService,
) {}
public function index(Organisation $organisation): JsonResponse
{
Gate::authorize('update', $organisation);
$templates = $this->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;
}
}

View File

@@ -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,
);
}
);

View File

@@ -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')));

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
final class UpdateEmailSettingsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'logo_url' => ['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'],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
final class UpdateEmailTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'subject' => ['required', 'string', 'max:200'],
'heading' => ['nullable', 'string', 'max:200'],
'body_text' => ['required', 'string', 'max:5000'],
'button_text' => ['nullable', 'string', 'max:100'],
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class EmailLogResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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(),
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class EmailSettingsResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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(),
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class EmailTemplateResource extends JsonResource
{
/**
* @param Request $request
* @return array<string, mixed>
*/
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(),
];
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Enums\EmailLogStatus;
use App\Enums\EmailTemplateType;
use App\Mail\TransactionalMail;
use App\Models\EmailLog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
final class SendTransactionalEmail implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
/** @var list<int> */
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;
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
final class TransactionalMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly array $template,
public readonly array $branding,
public readonly ?string $actionUrl = null,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->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'],
],
);
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\EmailLogStatus;
use App\Enums\EmailTemplateType;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class EmailLog extends Model
{
use HasFactory;
use HasUlids;
protected $fillable = [
'organisation_id',
'event_id',
'person_id',
'user_id',
'recipient_email',
'recipient_name',
'mailable_class',
'template_type',
'subject',
'status',
'error_message',
'queued_at',
'sent_at',
'failed_at',
'triggered_by_user_id',
];
protected function casts(): array
{
return [
'template_type' => 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');
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class OrganisationEmailSettings extends Model
{
use HasFactory;
use HasUlids;
protected $fillable = [
'organisation_id',
'logo_url',
'primary_color',
'secondary_color',
'footer_text',
'reply_to_email',
'reply_to_name',
];
public function organisation(): BelongsTo
{
return $this->belongsTo(Organisation::class);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\EmailTemplateType;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class OrganisationEmailTemplate extends Model
{
use HasFactory;
use HasUlids;
protected $fillable = [
'organisation_id',
'type',
'subject',
'heading',
'body_text',
'button_text',
];
protected function casts(): array
{
return [
'type' => EmailTemplateType::class,
];
}
public function organisation(): BelongsTo
{
return $this->belongsTo(Organisation::class);
}
}

View File

@@ -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)

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Services;
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 App\Models\OrganisationEmailSettings;
use App\Models\OrganisationEmailTemplate;
final class EmailService
{
/**
* Queue a transactional email.
* This is the central entry point for sending all emails in Crewli.
*/
public function send(
EmailTemplateType $type,
string $recipientEmail,
string $recipientName,
array $variables = [],
?string $actionUrl = null,
?Organisation $organisation = null,
?string $eventId = null,
?string $personId = null,
?string $userId = null,
?string $triggeredByUserId = null,
): EmailLog {
$template = $this->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<array{type: string, label: string, is_custom: bool, subject: string, heading: string|null, body_text: string, button_text: string|null, defaults: array}>
*/
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;
}
}

View File

@@ -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)

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\EmailLogStatus;
use App\Enums\EmailTemplateType;
use App\Mail\TransactionalMail;
use App\Models\EmailLog;
use App\Models\Organisation;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<EmailLog> */
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',
]);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Organisation;
use App\Models\OrganisationEmailSettings;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<OrganisationEmailSettings> */
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(),
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\EmailTemplateType;
use App\Models\Organisation;
use App\Models\OrganisationEmailTemplate;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<OrganisationEmailTemplate> */
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]);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('organisation_email_settings', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('organisation_email_templates', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('email_logs', function (Blueprint $table) {
$table->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');
}
};

View File

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

View File

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

View File

@@ -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']);

View File

@@ -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;
});
}

View File

@@ -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',

View File

@@ -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;
});
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Email;
use App\Enums\EmailLogStatus;
use App\Enums\EmailTemplateType;
use App\Models\EmailLog;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class EmailLogControllerTest extends TestCase
{
use RefreshDatabase;
private User $orgAdmin;
private User $outsider;
private Organisation $organisation;
private Organisation $otherOrganisation;
protected function setUp(): void
{
parent::setUp();
$this->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();
}
}

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Email;
use App\Enums\EmailLogStatus;
use App\Enums\EmailTemplateType;
use App\Jobs\SendTransactionalEmail;
use App\Models\Organisation;
use App\Models\OrganisationEmailSettings;
use App\Models\OrganisationEmailTemplate;
use App\Services\EmailService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class EmailServiceTest extends TestCase
{
use RefreshDatabase;
private EmailService $service;
private Organisation $organisation;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}
}

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Email;
use App\Enums\EmailTemplateType;
use App\Models\Organisation;
use App\Models\OrganisationEmailSettings;
use App\Models\OrganisationEmailTemplate;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class EmailSettingsAndTemplateControllerTest extends TestCase
{
use RefreshDatabase;
private User $orgAdmin;
private User $outsider;
private Organisation $organisation;
private Organisation $otherOrganisation;
protected function setUp(): void
{
parent::setUp();
$this->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('<!DOCTYPE html>', $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();
}
}

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');
});
}
}

View File

@@ -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

View File

@@ -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', [