Files
crewli/api/app/Services/EmailService.php
bert.hausmans 65978104d8 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>
2026-04-15 20:12:21 +02:00

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