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:
@@ -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
|
||||
|
||||
12
api/app/Enums/EmailLogStatus.php
Normal file
12
api/app/Enums/EmailLogStatus.php
Normal 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';
|
||||
}
|
||||
75
api/app/Enums/EmailTemplateType.php
Normal file
75
api/app/Enums/EmailTemplateType.php
Normal 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',
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
63
api/app/Http/Controllers/Api/V1/EmailLogController.php
Normal file
63
api/app/Http/Controllers/Api/V1/EmailLogController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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')));
|
||||
|
||||
27
api/app/Http/Requests/Api/V1/UpdateEmailSettingsRequest.php
Normal file
27
api/app/Http/Requests/Api/V1/UpdateEmailSettingsRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
25
api/app/Http/Requests/Api/V1/UpdateEmailTemplateRequest.php
Normal file
25
api/app/Http/Requests/Api/V1/UpdateEmailTemplateRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
35
api/app/Http/Resources/Api/V1/EmailLogResource.php
Normal file
35
api/app/Http/Resources/Api/V1/EmailLogResource.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
27
api/app/Http/Resources/Api/V1/EmailSettingsResource.php
Normal file
27
api/app/Http/Resources/Api/V1/EmailSettingsResource.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
37
api/app/Http/Resources/Api/V1/EmailTemplateResource.php
Normal file
37
api/app/Http/Resources/Api/V1/EmailTemplateResource.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
83
api/app/Jobs/SendTransactionalEmail.php
Normal file
83
api/app/Jobs/SendTransactionalEmail.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
api/app/Mail/TransactionalMail.php
Normal file
48
api/app/Mail/TransactionalMail.php
Normal 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'],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
72
api/app/Models/EmailLog.php
Normal file
72
api/app/Models/EmailLog.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
31
api/app/Models/OrganisationEmailSettings.php
Normal file
31
api/app/Models/OrganisationEmailSettings.php
Normal 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);
|
||||
}
|
||||
}
|
||||
38
api/app/Models/OrganisationEmailTemplate.php
Normal file
38
api/app/Models/OrganisationEmailTemplate.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
169
api/app/Services/EmailService.php
Normal file
169
api/app/Services/EmailService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
55
api/database/factories/EmailLogFactory.php
Normal file
55
api/database/factories/EmailLogFactory.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
28
api/database/factories/OrganisationEmailSettingsFactory.php
Normal file
28
api/database/factories/OrganisationEmailSettingsFactory.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
35
api/database/factories/OrganisationEmailTemplateFactory.php
Normal file
35
api/database/factories/OrganisationEmailTemplateFactory.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
81
api/resources/views/emails/transactional.blade.php
Normal file
81
api/resources/views/emails/transactional.blade.php
Normal 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>
|
||||
12
api/resources/views/emails/transactional_text.blade.php
Normal file
12
api/resources/views/emails/transactional_text.blade.php
Normal file
@@ -0,0 +1,12 @@
|
||||
@if($heading)
|
||||
{{ $heading }}
|
||||
|
||||
@endif
|
||||
{{ $bodyText }}
|
||||
@if($buttonText && $actionUrl)
|
||||
|
||||
{{ $buttonText }}: {{ $actionUrl }}
|
||||
@endif
|
||||
|
||||
---
|
||||
{{ $footerText ?? 'Crewli' }}
|
||||
@@ -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']);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
162
api/tests/Feature/Email/EmailLogControllerTest.php
Normal file
162
api/tests/Feature/Email/EmailLogControllerTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
173
api/tests/Feature/Email/EmailServiceTest.php
Normal file
173
api/tests/Feature/Email/EmailServiceTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
178
api/tests/Feature/Email/SendTransactionalEmailJobTest.php
Normal file
178
api/tests/Feature/Email/SendTransactionalEmailJobTest.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Email;
|
||||
|
||||
use App\Enums\EmailLogStatus;
|
||||
use App\Enums\EmailTemplateType;
|
||||
use App\Jobs\SendTransactionalEmail;
|
||||
use App\Mail\TransactionalMail;
|
||||
use App\Models\EmailLog;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SendTransactionalEmailJobTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private array $template;
|
||||
private array $branding;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->template = [
|
||||
'subject' => 'Test Subject',
|
||||
'heading' => 'Test Heading',
|
||||
'body_text' => 'Test body text',
|
||||
'button_text' => 'Click me',
|
||||
'is_custom' => false,
|
||||
];
|
||||
|
||||
$this->branding = [
|
||||
'logo_url' => null,
|
||||
'primary_color' => '#6366F1',
|
||||
'secondary_color' => '#4F46E5',
|
||||
'footer_text' => '© 2026 Crewli',
|
||||
'reply_to_email' => null,
|
||||
'reply_to_name' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function test_job_sends_email_and_updates_log_to_sent(): void
|
||||
{
|
||||
Mail::fake();
|
||||
|
||||
$org = Organisation::factory()->create();
|
||||
$log = EmailLog::factory()->create([
|
||||
'organisation_id' => $org->id,
|
||||
'status' => EmailLogStatus::QUEUED->value,
|
||||
]);
|
||||
|
||||
$job = new SendTransactionalEmail(
|
||||
emailLogId: $log->id,
|
||||
type: EmailTemplateType::INVITATION,
|
||||
recipientEmail: 'test@example.com',
|
||||
recipientName: 'Test User',
|
||||
template: $this->template,
|
||||
branding: $this->branding,
|
||||
actionUrl: 'https://example.com/action',
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
Mail::assertSent(TransactionalMail::class, function ($mailable) {
|
||||
return $mailable->template['subject'] === 'Test Subject';
|
||||
});
|
||||
|
||||
$log->refresh();
|
||||
$this->assertEquals(EmailLogStatus::SENT, $log->status);
|
||||
$this->assertNotNull($log->sent_at);
|
||||
}
|
||||
|
||||
public function test_job_skips_already_sent_emails(): void
|
||||
{
|
||||
Mail::fake();
|
||||
|
||||
$org = Organisation::factory()->create();
|
||||
$log = EmailLog::factory()->sent()->create([
|
||||
'organisation_id' => $org->id,
|
||||
]);
|
||||
|
||||
$job = new SendTransactionalEmail(
|
||||
emailLogId: $log->id,
|
||||
type: EmailTemplateType::INVITATION,
|
||||
recipientEmail: 'test@example.com',
|
||||
recipientName: 'Test User',
|
||||
template: $this->template,
|
||||
branding: $this->branding,
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_job_updates_log_to_failed_on_exception(): void
|
||||
{
|
||||
Mail::fake();
|
||||
Mail::shouldReceive('to')->andThrow(new \RuntimeException('SMTP error'));
|
||||
|
||||
$org = Organisation::factory()->create();
|
||||
$log = EmailLog::factory()->create([
|
||||
'organisation_id' => $org->id,
|
||||
'status' => EmailLogStatus::QUEUED->value,
|
||||
]);
|
||||
|
||||
$job = new SendTransactionalEmail(
|
||||
emailLogId: $log->id,
|
||||
type: EmailTemplateType::INVITATION,
|
||||
recipientEmail: 'test@example.com',
|
||||
recipientName: 'Test User',
|
||||
template: $this->template,
|
||||
branding: $this->branding,
|
||||
);
|
||||
|
||||
try {
|
||||
$job->handle();
|
||||
} catch (\RuntimeException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
$log->refresh();
|
||||
$this->assertEquals(EmailLogStatus::FAILED, $log->status);
|
||||
$this->assertNotNull($log->failed_at);
|
||||
$this->assertStringContainsString('SMTP error', $log->error_message);
|
||||
}
|
||||
|
||||
public function test_job_retries_on_failure(): void
|
||||
{
|
||||
$job = new SendTransactionalEmail(
|
||||
emailLogId: 'fake-id',
|
||||
type: EmailTemplateType::INVITATION,
|
||||
recipientEmail: 'test@example.com',
|
||||
recipientName: 'Test User',
|
||||
template: $this->template,
|
||||
branding: $this->branding,
|
||||
);
|
||||
|
||||
$this->assertEquals(3, $job->tries);
|
||||
$this->assertEquals([30, 120, 300], $job->backoff);
|
||||
$this->assertEquals('emails', $job->queue);
|
||||
}
|
||||
|
||||
public function test_job_sets_reply_to_when_configured(): void
|
||||
{
|
||||
Mail::fake();
|
||||
|
||||
$org = Organisation::factory()->create();
|
||||
$log = EmailLog::factory()->create([
|
||||
'organisation_id' => $org->id,
|
||||
'status' => EmailLogStatus::QUEUED->value,
|
||||
]);
|
||||
|
||||
$brandingWithReplyTo = array_merge($this->branding, [
|
||||
'reply_to_email' => 'reply@example.com',
|
||||
'reply_to_name' => 'Reply User',
|
||||
]);
|
||||
|
||||
$job = new SendTransactionalEmail(
|
||||
emailLogId: $log->id,
|
||||
type: EmailTemplateType::INVITATION,
|
||||
recipientEmail: 'test@example.com',
|
||||
recipientName: 'Test User',
|
||||
template: $this->template,
|
||||
branding: $brandingWithReplyTo,
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
Mail::assertSent(TransactionalMail::class, function ($mailable) {
|
||||
return $mailable->hasReplyTo('reply@example.com', 'Reply User');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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', [
|
||||
|
||||
Reference in New Issue
Block a user