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>
170 lines
5.6 KiB
PHP
170 lines
5.6 KiB
PHP
<?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;
|
|
}
|
|
}
|