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:
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
41
api/app/Mail/CrewliMailable.php
Normal file
41
api/app/Mail/CrewliMailable.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
25
api/app/Services/MailBrandingService.php
Normal file
25
api/app/Services/MailBrandingService.php
Normal 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
7
api/config/crewli.php
Normal 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'),
|
||||
];
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
15
api/resources/views/mail/invitation.blade.php
Normal file
15
api/resources/views/mail/invitation.blade.php
Normal 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
|
||||
70
api/resources/views/mail/layouts/crewli.blade.php
Normal file
70
api/resources/views/mail/layouts/crewli.blade.php
Normal 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>
|
||||
17
api/resources/views/mail/registration-approved.blade.php
Normal file
17
api/resources/views/mail/registration-approved.blade.php
Normal 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
|
||||
24
api/resources/views/mail/registration-confirmation.blade.php
Normal file
24
api/resources/views/mail/registration-confirmation.blade.php
Normal 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
|
||||
17
api/resources/views/mail/registration-rejected.blade.php
Normal file
17
api/resources/views/mail/registration-rejected.blade.php
Normal 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
27
api/routes/web.php
Normal 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),
|
||||
};
|
||||
});
|
||||
}
|
||||
90
api/tests/Feature/Mail/MailBrandingServiceTest.php
Normal file
90
api/tests/Feature/Mail/MailBrandingServiceTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
157
api/tests/Feature/Mail/MailLayoutTest.php
Normal file
157
api/tests/Feature/Mail/MailLayoutTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
138
api/tests/Feature/Organisation/OrganisationEmailBrandingTest.php
Normal file
138
api/tests/Feature/Organisation/OrganisationEmailBrandingTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user