feat(api): organisation email branding and shared mail layout

- Add email branding columns to organisations table (logo, color, reply-to, sender name, footer)
- Create MailBrandingService for resolving per-org branding with defaults
- Create CrewliMailable abstract base class with branded from/reply-to
- Create shared Blade layout (mail.layouts.crewli) with inline CSS
- Refactor Registration*Mail and InvitationMail to extend CrewliMailable
- Add config/crewli.php for platform-wide defaults (portal_url, app_url, logo)
- Add dev-only /mail-preview/{type} route for browser email previewing
- Update Organisation model, resource, and form requests with branding fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 00:44:34 +02:00
parent de8ebf724b
commit ec4ba8733d
21 changed files with 739 additions and 60 deletions

View File

@@ -21,6 +21,11 @@ final class StoreOrganisationRequest extends FormRequest
'slug' => ['required', 'string', 'max:255', 'unique:organisations,slug', 'regex:/^[a-z0-9-]+$/'],
'billing_status' => ['sometimes', 'string', 'in:trial,active,suspended,cancelled'],
'settings' => ['sometimes', 'array'],
'email_logo_url' => ['nullable', 'url', 'max:500'],
'email_primary_color' => ['nullable', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'email_reply_to' => ['nullable', 'email'],
'email_sender_name' => ['nullable', 'string', 'max:100'],
'email_footer_text' => ['nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -25,6 +25,11 @@ final class UpdateOrganisationRequest extends FormRequest
],
'billing_status' => ['sometimes', 'string', 'in:active,trial,suspended'],
'settings' => ['sometimes', 'array'],
'email_logo_url' => ['nullable', 'url', 'max:500'],
'email_primary_color' => ['nullable', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'email_reply_to' => ['nullable', 'email'],
'email_sender_name' => ['nullable', 'string', 'max:100'],
'email_footer_text' => ['nullable', 'string', 'max:2000'],
];
}
}

View File

@@ -17,6 +17,11 @@ final class OrganisationResource extends JsonResource
'slug' => $this->slug,
'billing_status' => $this->billing_status,
'settings' => $this->settings,
'email_logo_url' => $this->email_logo_url,
'email_primary_color' => $this->email_primary_color,
'email_reply_to' => $this->email_reply_to,
'email_sender_name' => $this->email_sender_name,
'email_footer_text' => $this->email_footer_text,
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
];

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Organisation;
use App\Services\MailBrandingService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
abstract class CrewliMailable extends Mailable implements ShouldQueue
{
use Queueable;
use SerializesModels;
protected Organisation $organisation;
public function __construct(Organisation $organisation)
{
$this->organisation = $organisation;
}
protected function buildBranding(): static
{
$branding = app(MailBrandingService::class)->getBranding($this->organisation);
$this->from(
config('mail.from.address'),
$branding['sender_name']
);
if ($branding['reply_to']) {
$this->replyTo($branding['reply_to']);
}
return $this->with('branding', $branding);
}
}

View File

@@ -5,21 +5,18 @@ declare(strict_types=1);
namespace App\Mail;
use App\Models\UserInvitation;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
final class InvitationMail extends Mailable implements ShouldQueue
final class InvitationMail extends CrewliMailable
{
use Queueable;
use SerializesModels;
public UserInvitation $invitation;
public function __construct(
public readonly UserInvitation $invitation,
) {}
public function __construct(UserInvitation $invitation)
{
parent::__construct($invitation->organisation);
$this->invitation = $invitation;
}
public function envelope(): Envelope
{
@@ -30,11 +27,12 @@ final class InvitationMail extends Mailable implements ShouldQueue
public function content(): Content
{
$this->buildBranding();
return new Content(
markdown: 'emails.invitation',
view: 'mail.invitation',
with: [
'acceptUrl' => config('app.frontend_app_url') . '/invitations/' . $this->invitation->token . '/accept',
'organisationName' => $this->invitation->organisation->name,
'acceptUrl' => config('crewli.app_url') . '/invitations/' . $this->invitation->token . '/accept',
'inviterName' => $this->invitation->invitedBy?->name ?? 'Een beheerder',
'role' => $this->invitation->role,
'expiresAt' => $this->invitation->expires_at,

View File

@@ -6,22 +6,20 @@ namespace App\Mail;
use App\Models\Event;
use App\Models\Person;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
final class RegistrationApprovedMail extends Mailable implements ShouldQueue
final class RegistrationApprovedMail extends CrewliMailable
{
use Queueable;
use SerializesModels;
public Person $person;
public Event $event;
public function __construct(
public readonly Person $person,
public readonly Event $event,
) {}
public function __construct(Person $person, Event $event)
{
parent::__construct($event->organisation);
$this->person = $person;
$this->event = $event;
}
public function envelope(): Envelope
{
@@ -32,12 +30,14 @@ final class RegistrationApprovedMail extends Mailable implements ShouldQueue
public function content(): Content
{
$this->buildBranding();
return new Content(
markdown: 'emails.registration-approved',
view: 'mail.registration-approved',
with: [
'personName' => $this->person->first_name,
'eventName' => $this->event->name,
'portalUrl' => config('app.frontend_portal_url'),
'person' => $this->person,
'event' => $this->event,
'portalUrl' => config('crewli.portal_url'),
],
);
}

View File

@@ -6,22 +6,20 @@ namespace App\Mail;
use App\Models\Event;
use App\Models\Person;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
final class RegistrationConfirmationMail extends Mailable implements ShouldQueue
final class RegistrationConfirmationMail extends CrewliMailable
{
use Queueable;
use SerializesModels;
public Person $person;
public Event $event;
public function __construct(
public readonly Person $person,
public readonly Event $event,
) {}
public function __construct(Person $person, Event $event)
{
parent::__construct($event->organisation);
$this->person = $person;
$this->event = $event;
}
public function envelope(): Envelope
{
@@ -32,14 +30,14 @@ final class RegistrationConfirmationMail extends Mailable implements ShouldQueue
public function content(): Content
{
$this->buildBranding();
return new Content(
markdown: 'emails.registration-confirmation',
view: 'mail.registration-confirmation',
with: [
'personName' => $this->person->first_name,
'eventName' => $this->event->name,
'startDate' => $this->event->start_date->format('d-m-Y'),
'endDate' => $this->event->end_date->format('d-m-Y'),
'portalUrl' => config('app.frontend_portal_url'),
'person' => $this->person,
'event' => $this->event,
'portalUrl' => config('crewli.portal_url'),
],
);
}

View File

@@ -6,23 +6,22 @@ namespace App\Mail;
use App\Models\Event;
use App\Models\Person;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
final class RegistrationRejectedMail extends Mailable implements ShouldQueue
final class RegistrationRejectedMail extends CrewliMailable
{
use Queueable;
use SerializesModels;
public Person $person;
public Event $event;
public ?string $reason;
public function __construct(
public readonly Person $person,
public readonly Event $event,
public readonly ?string $reason = null,
) {}
public function __construct(Person $person, Event $event, ?string $reason = null)
{
parent::__construct($event->organisation);
$this->person = $person;
$this->event = $event;
$this->reason = $reason;
}
public function envelope(): Envelope
{
@@ -33,11 +32,13 @@ final class RegistrationRejectedMail extends Mailable implements ShouldQueue
public function content(): Content
{
$this->buildBranding();
return new Content(
markdown: 'emails.registration-rejected',
view: 'mail.registration-rejected',
with: [
'personName' => $this->person->first_name,
'eventName' => $this->event->name,
'person' => $this->person,
'event' => $this->event,
'reason' => $this->reason,
],
);

View File

@@ -22,6 +22,11 @@ final class Organisation extends Model
'slug',
'billing_status',
'settings',
'email_logo_url',
'email_primary_color',
'email_reply_to',
'email_sender_name',
'email_footer_text',
];
protected function casts(): array

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Organisation;
final class MailBrandingService
{
/**
* @return array<string, mixed>
*/
public function getBranding(Organisation $organisation): array
{
return [
'organisation_name' => $organisation->name,
'logo_url' => $organisation->email_logo_url ?? config('crewli.default_logo_url'),
'primary_color' => $organisation->email_primary_color ?? '#6366f1',
'reply_to' => $organisation->email_reply_to,
'sender_name' => $organisation->email_sender_name ?? $organisation->name,
'footer_text' => $organisation->email_footer_text,
];
}
}

7
api/config/crewli.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
return [
'default_logo_url' => env('CREWLI_DEFAULT_LOGO_URL'),
'portal_url' => env('CREWLI_PORTAL_URL', 'http://localhost:5175'),
'app_url' => env('CREWLI_APP_URL', 'http://localhost:5174'),
];

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('organisations', function (Blueprint $table) {
$table->string('email_logo_url', 500)->nullable()->after('settings');
$table->string('email_primary_color', 7)->nullable()->after('email_logo_url');
$table->string('email_reply_to')->nullable()->after('email_primary_color');
$table->string('email_sender_name', 100)->nullable()->after('email_reply_to');
$table->text('email_footer_text')->nullable()->after('email_sender_name');
});
}
public function down(): void
{
Schema::table('organisations', function (Blueprint $table) {
$table->dropColumn([
'email_logo_url',
'email_primary_color',
'email_reply_to',
'email_sender_name',
'email_footer_text',
]);
});
}
};

View File

@@ -0,0 +1,15 @@
@extends('mail.layouts.crewli')
@section('title')
Je bent uitgenodigd!
@endsection
@section('content')
<p style="margin: 0 0 16px;">{{ $inviterName }} heeft je uitgenodigd om deel te nemen als <strong>{{ $role }}</strong>.</p>
<p style="margin: 0;">Deze uitnodiging verloopt op <strong>{{ $expiresAt->format('d-m-Y H:i') }}</strong> (over 7 dagen).</p>
@endsection
@section('action')
<a href="{{ $acceptUrl }}" style="display: inline-block; padding: 12px 24px; background-color: {{ $branding['primary_color'] }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 600;">Uitnodiging accepteren</a>
@endsection

View File

@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title')</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
<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: {{ $branding['primary_color'] }};"></td>
</tr>
{{-- Header: logo + org name --}}
<tr>
<td align="center" style="padding: 32px 40px 24px;">
@if($branding['logo_url'])
<img src="{{ $branding['logo_url'] }}" alt="{{ $branding['organisation_name'] }}" style="max-height: 60px; max-width: 200px; display: block; margin-bottom: 12px;">
@endif
<p style="margin: 0; font-size: 15px; color: #6b7280; font-weight: 500;">{{ $branding['organisation_name'] }}</p>
</td>
</tr>
{{-- Body --}}
<tr>
<td style="padding: 0 40px 32px;">
{{-- Title --}}
<h1 style="margin: 0 0 20px; font-size: 22px; font-weight: 700; color: #1f2937; line-height: 1.3;">
@yield('title')
</h1>
{{-- Content --}}
<div style="font-size: 16px; color: #1f2937; line-height: 1.6;">
@yield('content')
</div>
{{-- Action button --}}
@hasSection('action')
<div style="margin-top: 28px;">
@yield('action')
</div>
@endif
</td>
</tr>
{{-- Footer --}}
<tr>
<td style="padding: 24px 40px; border-top: 1px solid #e5e7eb;">
@if($branding['footer_text'])
<p style="margin: 0 0 16px; font-size: 13px; color: #6b7280; text-align: center; line-height: 1.5;">
{{ $branding['footer_text'] }}
</p>
@endif
<p style="margin: 0; font-size: 11px; color: #9ca3af; text-align: center; line-height: 1.5;">
Powered by Crewli<br>
Je ontvangt deze email omdat je bent aangemeld bij {{ $branding['organisation_name'] }}.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,17 @@
@extends('mail.layouts.crewli')
@section('title')
Goed nieuws!
@endsection
@section('content')
<p style="margin: 0 0 16px;">Beste {{ $person->first_name }},</p>
<p style="margin: 0 0 16px;">Goed nieuws! Je bent goedgekeurd als vrijwilliger voor <strong>{{ $event->name }}</strong>.</p>
<p style="margin: 0;">Log in op het portaal om je shifts te bekijken en te claimen.</p>
@endsection
@section('action')
<a href="{{ $portalUrl }}" style="display: inline-block; padding: 12px 24px; background-color: {{ $branding['primary_color'] }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 600;">Naar het portaal</a>
@endsection

View File

@@ -0,0 +1,24 @@
@extends('mail.layouts.crewli')
@section('title')
Bedankt voor je aanmelding!
@endsection
@section('content')
<p style="margin: 0 0 16px;">Beste {{ $person->first_name }},</p>
<p style="margin: 0 0 16px;">Bedankt voor je aanmelding als vrijwilliger voor <strong>{{ $event->name }}</strong>!</p>
<p style="margin: 0 0 16px;">Je registratie wordt beoordeeld door de organisatie. Je ontvangt een e-mail zodra je aanmelding is verwerkt.</p>
<p style="margin: 0 0 16px;">Je kunt inloggen op het vrijwilligersportaal om je status te volgen.</p>
<p style="margin: 0;">
<strong>Evenement:</strong> {{ $event->name }}<br>
<strong>Datum:</strong> {{ $event->start_date->format('d-m-Y') }} t/m {{ $event->end_date->format('d-m-Y') }}
</p>
@endsection
@section('action')
<a href="{{ $portalUrl }}" style="display: inline-block; padding: 12px 24px; background-color: {{ $branding['primary_color'] }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 600;">Naar het portaal</a>
@endsection

View File

@@ -0,0 +1,17 @@
@extends('mail.layouts.crewli')
@section('title')
Update over je aanmelding
@endsection
@section('content')
<p style="margin: 0 0 16px;">Beste {{ $person->first_name }},</p>
<p style="margin: 0 0 16px;">Helaas hebben we je aanmelding voor <strong>{{ $event->name }}</strong> niet kunnen goedkeuren.</p>
@if($reason)
<p style="margin: 0 0 16px;"><strong>Reden:</strong> {{ $reason }}</p>
@endif
<p style="margin: 0;">Neem contact op met de organisatie als je vragen hebt.</p>
@endsection

27
api/routes/web.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
use App\Mail\InvitationMail;
use App\Mail\RegistrationApprovedMail;
use App\Mail\RegistrationConfirmationMail;
use App\Mail\RegistrationRejectedMail;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Person;
use App\Models\UserInvitation;
use Illuminate\Support\Facades\Route;
if (app()->environment('local', 'staging')) {
Route::get('/mail-preview/{type}', function (string $type) {
$org = Organisation::first();
$event = Event::first();
$person = Person::first();
return match ($type) {
'registration-confirmation' => new RegistrationConfirmationMail($person, $event),
'registration-approved' => new RegistrationApprovedMail($person, $event),
'registration-rejected' => new RegistrationRejectedMail($person, $event, 'Helaas geen plek meer.'),
'invitation' => new InvitationMail(UserInvitation::first()),
default => abort(404),
};
});
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Mail;
use App\Models\Organisation;
use App\Services\MailBrandingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class MailBrandingServiceTest extends TestCase
{
use RefreshDatabase;
private MailBrandingService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = new MailBrandingService();
}
public function test_returns_org_branding_when_configured(): void
{
$org = Organisation::factory()->create([
'name' => 'Festival BV',
'email_logo_url' => 'https://example.com/logo.png',
'email_primary_color' => '#ff5500',
'email_reply_to' => 'info@festival.nl',
'email_sender_name' => 'Festival Team',
'email_footer_text' => 'Stichting Festival, Amsterdam',
]);
$branding = $this->service->getBranding($org);
$this->assertEquals('Festival BV', $branding['organisation_name']);
$this->assertEquals('https://example.com/logo.png', $branding['logo_url']);
$this->assertEquals('#ff5500', $branding['primary_color']);
$this->assertEquals('info@festival.nl', $branding['reply_to']);
$this->assertEquals('Festival Team', $branding['sender_name']);
$this->assertEquals('Stichting Festival, Amsterdam', $branding['footer_text']);
}
public function test_falls_back_to_defaults_when_not_configured(): void
{
$org = Organisation::factory()->create([
'name' => 'Lege Org',
'email_logo_url' => null,
'email_primary_color' => null,
'email_reply_to' => null,
'email_sender_name' => null,
'email_footer_text' => null,
]);
$branding = $this->service->getBranding($org);
$this->assertEquals('Lege Org', $branding['organisation_name']);
$this->assertNull($branding['logo_url']);
$this->assertEquals('#6366f1', $branding['primary_color']);
$this->assertNull($branding['reply_to']);
$this->assertEquals('Lege Org', $branding['sender_name']);
$this->assertNull($branding['footer_text']);
}
public function test_sender_name_falls_back_to_org_name(): void
{
$org = Organisation::factory()->create([
'name' => 'Mijn Organisatie',
'email_sender_name' => null,
]);
$branding = $this->service->getBranding($org);
$this->assertEquals('Mijn Organisatie', $branding['sender_name']);
}
public function test_logo_url_falls_back_to_config_default(): void
{
config(['crewli.default_logo_url' => 'https://crewli.app/logo.png']);
$org = Organisation::factory()->create([
'email_logo_url' => null,
]);
$branding = $this->service->getBranding($org);
$this->assertEquals('https://crewli.app/logo.png', $branding['logo_url']);
}
}

View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Mail;
use App\Mail\CrewliMailable;
use App\Mail\InvitationMail;
use App\Mail\RegistrationApprovedMail;
use App\Mail\RegistrationConfirmationMail;
use App\Mail\RegistrationRejectedMail;
use App\Models\CrowdType;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Person;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class MailLayoutTest extends TestCase
{
use RefreshDatabase;
private Organisation $organisation;
private Event $event;
private Person $person;
protected function setUp(): void
{
parent::setUp();
$this->organisation = Organisation::factory()->create([
'name' => 'Test Festival BV',
'email_logo_url' => 'https://example.com/logo.png',
'email_primary_color' => '#e11d48',
'email_footer_text' => 'Stichting Test Festival, Utrecht',
]);
$this->event = Event::factory()->create([
'organisation_id' => $this->organisation->id,
'status' => 'registration_open',
]);
$crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([
'organisation_id' => $this->organisation->id,
]);
$this->person = Person::factory()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $crowdType->id,
'first_name' => 'Jan',
'last_name' => 'Tester',
'email' => 'jan@test.nl',
]);
}
public function test_confirmation_email_renders_with_org_logo(): void
{
$mail = new RegistrationConfirmationMail($this->person, $this->event);
$html = $mail->render();
$this->assertStringContainsString('https://example.com/logo.png', $html);
$this->assertStringContainsString('Test Festival BV', $html);
}
public function test_confirmation_email_renders_with_primary_color(): void
{
$mail = new RegistrationConfirmationMail($this->person, $this->event);
$html = $mail->render();
$this->assertStringContainsString('#e11d48', $html);
}
public function test_confirmation_email_renders_with_custom_footer(): void
{
$mail = new RegistrationConfirmationMail($this->person, $this->event);
$html = $mail->render();
$this->assertStringContainsString('Stichting Test Festival, Utrecht', $html);
}
public function test_confirmation_email_renders_with_defaults_when_no_branding(): void
{
$plainOrg = Organisation::factory()->create([
'name' => 'Plain Org',
'email_logo_url' => null,
'email_primary_color' => null,
'email_footer_text' => null,
]);
$event = Event::factory()->create([
'organisation_id' => $plainOrg->id,
'status' => 'registration_open',
]);
$crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([
'organisation_id' => $plainOrg->id,
]);
$person = Person::factory()->create([
'event_id' => $event->id,
'crowd_type_id' => $crowdType->id,
]);
$mail = new RegistrationConfirmationMail($person, $event);
$html = $mail->render();
// Default primary color
$this->assertStringContainsString('#6366f1', $html);
// Org name is shown
$this->assertStringContainsString('Plain Org', $html);
// Powered by Crewli always present
$this->assertStringContainsString('Powered by Crewli', $html);
}
public function test_approved_email_renders_with_branding(): void
{
$mail = new RegistrationApprovedMail($this->person, $this->event);
$html = $mail->render();
$this->assertStringContainsString('https://example.com/logo.png', $html);
$this->assertStringContainsString('#e11d48', $html);
$this->assertStringContainsString('Goed nieuws', $html);
}
public function test_rejected_email_renders_with_reason(): void
{
$mail = new RegistrationRejectedMail($this->person, $this->event, 'Geen plek meer.');
$html = $mail->render();
$this->assertStringContainsString('Geen plek meer.', $html);
$this->assertStringContainsString('Test Festival BV', $html);
}
public function test_rejected_email_renders_without_reason(): void
{
$mail = new RegistrationRejectedMail($this->person, $this->event);
$html = $mail->render();
$this->assertStringNotContainsString('Reden:', $html);
}
public function test_all_mailables_extend_crewli_mailable(): void
{
$this->assertInstanceOf(CrewliMailable::class, new RegistrationConfirmationMail($this->person, $this->event));
$this->assertInstanceOf(CrewliMailable::class, new RegistrationApprovedMail($this->person, $this->event));
$this->assertInstanceOf(CrewliMailable::class, new RegistrationRejectedMail($this->person, $this->event));
}
public function test_powered_by_crewli_always_present(): void
{
$mail = new RegistrationConfirmationMail($this->person, $this->event);
$html = $mail->render();
$this->assertStringContainsString('Powered by Crewli', $html);
$this->assertStringContainsString('Je ontvangt deze email omdat je bent aangemeld bij', $html);
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Organisation;
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 OrganisationEmailBrandingTest extends TestCase
{
use RefreshDatabase;
private User $orgAdmin;
private Organisation $organisation;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->organisation = Organisation::factory()->create();
$this->orgAdmin = User::factory()->create();
$this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']);
}
public function test_update_organisation_with_email_branding(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->putJson("/api/v1/organisations/{$this->organisation->id}", [
'email_logo_url' => 'https://example.com/logo.png',
'email_primary_color' => '#ff5500',
'email_reply_to' => 'info@festival.nl',
'email_sender_name' => 'Festival Team',
'email_footer_text' => 'Stichting Festival, Amsterdam',
]);
$response->assertOk();
$this->assertDatabaseHas('organisations', [
'id' => $this->organisation->id,
'email_logo_url' => 'https://example.com/logo.png',
'email_primary_color' => '#ff5500',
'email_reply_to' => 'info@festival.nl',
'email_sender_name' => 'Festival Team',
'email_footer_text' => 'Stichting Festival, Amsterdam',
]);
}
public function test_validate_email_primary_color_hex_format(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->putJson("/api/v1/organisations/{$this->organisation->id}", [
'email_primary_color' => 'red',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors('email_primary_color');
}
public function test_validate_email_primary_color_accepts_valid_hex(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->putJson("/api/v1/organisations/{$this->organisation->id}", [
'email_primary_color' => '#aaBB11',
]);
$response->assertOk();
}
public function test_validate_email_reply_to_format(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->putJson("/api/v1/organisations/{$this->organisation->id}", [
'email_reply_to' => 'not-an-email',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors('email_reply_to');
}
public function test_validate_email_logo_url_format(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->putJson("/api/v1/organisations/{$this->organisation->id}", [
'email_logo_url' => 'not-a-url',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors('email_logo_url');
}
public function test_organisation_resource_includes_branding_fields(): void
{
$this->organisation->update([
'email_logo_url' => 'https://example.com/logo.png',
'email_primary_color' => '#ff5500',
'email_reply_to' => 'info@festival.nl',
'email_sender_name' => 'Festival Team',
'email_footer_text' => 'Voetje tekst',
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}");
$response->assertOk()
->assertJsonPath('data.email_logo_url', 'https://example.com/logo.png')
->assertJsonPath('data.email_primary_color', '#ff5500')
->assertJsonPath('data.email_reply_to', 'info@festival.nl')
->assertJsonPath('data.email_sender_name', 'Festival Team')
->assertJsonPath('data.email_footer_text', 'Voetje tekst');
}
public function test_branding_fields_nullable(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->putJson("/api/v1/organisations/{$this->organisation->id}", [
'email_logo_url' => null,
'email_primary_color' => null,
'email_reply_to' => null,
'email_sender_name' => null,
'email_footer_text' => null,
]);
$response->assertOk();
}
}