From 0c5a1ddb2352320dcabcee8ff682140398a4d84b Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 3 Jul 2026 17:09:05 +0000 Subject: [PATCH] Send Events mail from platform SMTP like Invoice, not owner Bird domain. Use EventMailer with MAIL_FROM and organiser Reply-To instead of the Bird API, which rewrote the From address onto the account's verified domain (e.g. climp.me). Redesign the speaker invitation to use the shared Events email layout. Co-authored-by: Cursor --- .env.example | 10 ++ app/Services/Events/EventCommsService.php | 8 +- app/Services/Events/EventEmailService.php | 50 ++++++++-- app/Services/Events/EventMailer.php | 98 +++++++++++++++++++ .../Events/EventRegistrationService.php | 2 + .../Events/EventSpeakerInviteService.php | 4 +- .../event-speaker-invite.blade.php | 73 +++++++++++--- tests/Feature/EventSpeakerInviteTest.php | 19 ++-- 8 files changed, 233 insertions(+), 31 deletions(-) create mode 100644 app/Services/Events/EventMailer.php diff --git a/.env.example b/.env.example index 5cc7faf..191c74f 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,16 @@ SMS_PLATFORM_API_URL=https://ladill.com/api SMS_API_KEY_EVENTS= SMS_DEFAULT_SENDER_ID=Ladill +# Transactional email (platform mailer — same pattern as Invoice) +MAIL_MAILER=smtp +MAIL_HOST=127.0.0.1 +MAIL_PORT=587 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_FROM_ADDRESS=events@ladill.com +MAIL_FROM_NAME="Ladill Events" + +# Legacy Bird SMTP API (unused for attendee/speaker mail; kept for future Bird integration) SMTP_PLATFORM_API_URL=https://ladill.com/api/smtp SMTP_API_KEY_EVENTS= EVENTS_SMTP_FROM=events@ladill.com diff --git a/app/Services/Events/EventCommsService.php b/app/Services/Events/EventCommsService.php index 07d7f05..787aa9e 100644 --- a/app/Services/Events/EventCommsService.php +++ b/app/Services/Events/EventCommsService.php @@ -58,6 +58,8 @@ class EventCommsService $programmeUrl = $programme?->publicUrl(); $joinUrl = $this->primaryJoinUrl($event); $ownerRef = (string) $event->user->public_id; + $organizerEmail = $event->user->email; + $organizerName = $event->user->name; $emailed = 0; $texted = 0; @@ -68,15 +70,15 @@ class EventCommsService if ($reg->attendee_email && in_array($mode, [self::MODE_PROGRAMME, self::MODE_BOTH], true) && $programmeUrl) { if ($mode === self::MODE_BOTH) { - $sent = $this->email->sendJoinAndProgramme($ownerRef, $reg->attendee_email, $eventName, $joinUrl, $programmeUrl, $reg->attendee_name); + $sent = $this->email->sendJoinAndProgramme($ownerRef, $reg->attendee_email, $eventName, $joinUrl, $programmeUrl, $reg->attendee_name, $organizerEmail, $organizerName); } else { - $sent = $this->email->sendProgrammeShare($ownerRef, $reg->attendee_email, $eventName, $programmeUrl, $reg->attendee_name); + $sent = $this->email->sendProgrammeShare($ownerRef, $reg->attendee_email, $eventName, $programmeUrl, $reg->attendee_name, $organizerEmail, $organizerName); } if ($sent) { $emailed++; } } elseif ($reg->attendee_email && $mode === self::MODE_JOIN && $joinUrl !== '') { - $sent = $this->email->sendJoinLink($ownerRef, $reg->attendee_email, $eventName, $joinUrl, $reg->attendee_name); + $sent = $this->email->sendJoinLink($ownerRef, $reg->attendee_email, $eventName, $joinUrl, $reg->attendee_name, $organizerEmail, $organizerName); if ($sent) { $emailed++; } diff --git a/app/Services/Events/EventEmailService.php b/app/Services/Events/EventEmailService.php index 9bb865c..facea97 100644 --- a/app/Services/Events/EventEmailService.php +++ b/app/Services/Events/EventEmailService.php @@ -2,16 +2,15 @@ namespace App\Services\Events; -use App\Services\Billing\PlatformEmailClient; use Illuminate\Support\Facades\View; class EventEmailService { - public function __construct(private readonly PlatformEmailClient $platform) {} + public function __construct(private readonly EventMailer $mailer) {} public function lastError(): ?string { - return $this->platform->lastError(); + return $this->mailer->lastError(); } public function sendProgrammeShare( @@ -20,6 +19,8 @@ class EventEmailService string $eventName, string $programmeUrl, ?string $attendeeName = null, + ?string $organizerEmail = null, + ?string $organizerName = null, ): bool { return $this->send( $ownerPublicId, @@ -27,6 +28,9 @@ class EventEmailService 'Programme for '.$eventName, 'mail.notifications.event-programme', compact('eventName', 'programmeUrl', 'attendeeName'), + $eventName, + $organizerEmail, + $organizerName, ); } @@ -36,6 +40,8 @@ class EventEmailService string $eventName, string $joinUrl, ?string $attendeeName = null, + ?string $organizerEmail = null, + ?string $organizerName = null, ): bool { return $this->send( $ownerPublicId, @@ -43,6 +49,9 @@ class EventEmailService 'Join '.$eventName, 'mail.notifications.event-join', compact('eventName', 'joinUrl', 'attendeeName'), + $eventName, + $organizerEmail, + $organizerName, ); } @@ -53,6 +62,8 @@ class EventEmailService string $joinUrl, string $programmeUrl, ?string $attendeeName = null, + ?string $organizerEmail = null, + ?string $organizerName = null, ): bool { return $this->send( $ownerPublicId, @@ -60,6 +71,9 @@ class EventEmailService $eventName.' — programme & join link', 'mail.notifications.event-join-programme', compact('eventName', 'joinUrl', 'programmeUrl', 'attendeeName'), + $eventName, + $organizerEmail, + $organizerName, ); } @@ -70,6 +84,8 @@ class EventEmailService string $badgeCode, ?string $joinUrl = null, ?string $attendeeName = null, + ?string $organizerEmail = null, + ?string $organizerName = null, ): bool { return $this->send( $ownerPublicId, @@ -77,6 +93,9 @@ class EventEmailService 'Registration confirmed — '.$eventName, 'mail.notifications.event-registration-confirmed', compact('eventName', 'badgeCode', 'joinUrl', 'attendeeName'), + $eventName, + $organizerEmail, + $organizerName, ); } @@ -86,6 +105,8 @@ class EventEmailService string $eventName, string $portalUrl, ?string $speakerName = null, + ?string $organizerEmail = null, + ?string $organizerName = null, ): bool { return $this->send( $ownerPublicId, @@ -97,20 +118,35 @@ class EventEmailService 'portalUrl' => $portalUrl, 'speakerName' => $speakerName, ], + $eventName, + $organizerEmail, + $organizerName, ); } /** @param array $viewData */ - private function send(string $ownerPublicId, string $to, string $subject, string $view, array $viewData): bool - { + private function send( + string $ownerPublicId, + string $to, + string $subject, + string $view, + array $viewData, + ?string $fromDisplayName = null, + ?string $replyToEmail = null, + ?string $replyToName = null, + ): bool { $html = View::make($view, $viewData)->render(); + $text = strip_tags(str_replace(['
', '
', '
'], "\n", $html)); - return $this->platform->send( + return $this->mailer->send( $ownerPublicId, $to, $subject, $html, - strip_tags(str_replace(['
', '
', '
'], "\n", $html)), + $text, + $fromDisplayName, + $replyToEmail, + $replyToName, ); } } diff --git a/app/Services/Events/EventMailer.php b/app/Services/Events/EventMailer.php new file mode 100644 index 0000000..8bb6847 --- /dev/null +++ b/app/Services/Events/EventMailer.php @@ -0,0 +1,98 @@ +lastError; + } + + public function isConfigured(): bool + { + return filter_var((string) config('mail.from.address'), FILTER_VALIDATE_EMAIL) !== false; + } + + public function send( + string $ownerPublicId, + string $to, + string $subject, + string $html, + ?string $text = null, + ?string $fromDisplayName = null, + ?string $replyToEmail = null, + ?string $replyToName = null, + ): bool { + $this->lastError = null; + + if (! $this->isConfigured()) { + $this->lastError = 'Outbound email is not configured on this Events instance.'; + + return false; + } + + if (! $this->billing->canAfford($ownerPublicId, self::EMAIL_PRICE_MINOR)) { + $this->lastError = 'Insufficient Ladill wallet balance. Add funds in Billing and try again.'; + + return false; + } + + $reference = 'events_email:'.Str::uuid(); + + try { + Mail::html($html, function ($message) use ($to, $subject, $text, $fromDisplayName, $replyToEmail, $replyToName): void { + $message->to($to) + ->subject($subject) + ->from( + (string) config('mail.from.address'), + $fromDisplayName ?: (string) config('mail.from.name'), + ); + + if ($replyToEmail) { + $message->replyTo($replyToEmail, $replyToName ?: null); + } + + if ($text) { + $message->text($text); + } + }); + } catch (\Throwable $e) { + Log::warning('Events mail send failed', ['error' => $e->getMessage(), 'to' => $to]); + $this->lastError = 'The email could not be sent. Please try again.'; + + return false; + } + + if (! $this->billing->debit( + $ownerPublicId, + self::EMAIL_PRICE_MINOR, + 'smtp', + 'events_email', + $reference, + null, + 'Events email to '.$to, + )) { + $this->lastError = 'Insufficient Ladill wallet balance. Add funds in Billing and try again.'; + + return false; + } + + return true; + } +} diff --git a/app/Services/Events/EventRegistrationService.php b/app/Services/Events/EventRegistrationService.php index 83b8654..bbd27f6 100644 --- a/app/Services/Events/EventRegistrationService.php +++ b/app/Services/Events/EventRegistrationService.php @@ -276,6 +276,8 @@ class EventRegistrationService $registration->badge_code, $joinUrl !== '' ? $joinUrl : null, $registration->attendee_name, + $registration->qrCode?->user?->email, + $registration->qrCode?->user?->name, ); } diff --git a/app/Services/Events/EventSpeakerInviteService.php b/app/Services/Events/EventSpeakerInviteService.php index a811550..f0d3339 100644 --- a/app/Services/Events/EventSpeakerInviteService.php +++ b/app/Services/Events/EventSpeakerInviteService.php @@ -226,7 +226,7 @@ class EventSpeakerInviteService return ['ok' => false, 'error' => 'Could not resolve the event owner account for sending email.']; } - if (! app(\App\Services\Billing\PlatformEmailClient::class)->isConfigured()) { + if (! app(EventMailer::class)->isConfigured()) { return ['ok' => false, 'error' => 'Outbound email is not configured on this Events instance.']; } @@ -244,6 +244,8 @@ class EventSpeakerInviteService $eventName, $portalUrl, $speaker['name'] ?? null, + $owner->email, + $owner->name, ); if (! $sent) { diff --git a/resources/views/mail/notifications/event-speaker-invite.blade.php b/resources/views/mail/notifications/event-speaker-invite.blade.php index fdbb17f..86396e0 100644 --- a/resources/views/mail/notifications/event-speaker-invite.blade.php +++ b/resources/views/mail/notifications/event-speaker-invite.blade.php @@ -1,12 +1,61 @@ - - - -@if($speakerName) -

Hi {{ $speakerName }},

-@endif -

You have been invited to speak at {{ $eventName }}.

-

Your speaker page has the programme, your session times, and links to join the virtual stage when it is time:

-

Open your speaker page

-

No attendee registration is required — use the join links on that page to enter the speaker waiting area.

- - +@extends('mail.notifications.layout') + +@section('email-header') + @include('mail.partials.brand-header', ['brand' => 'events']) +@endsection + +@section('email-footer') + @include('mail.partials.brand-footer', ['brand' => 'events']) +@endsection + +@section('content') +
+

Speaker invitation

+

{{ $eventName }}

+
+ +

+ You're invited to speak{{ $speakerName ? ', '.explode(' ', $speakerName)[0] : '' }} +

+ + + + + + + +
+ +
+ + + + +@endsection diff --git a/tests/Feature/EventSpeakerInviteTest.php b/tests/Feature/EventSpeakerInviteTest.php index d25475f..14a185b 100644 --- a/tests/Feature/EventSpeakerInviteTest.php +++ b/tests/Feature/EventSpeakerInviteTest.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Services\Events\EventSpeakerInviteService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Mail; use Tests\TestCase; class EventSpeakerInviteTest extends TestCase @@ -19,14 +20,17 @@ class EventSpeakerInviteTest extends TestCase $this->withoutMiddleware(\App\Http\Middleware\EnsurePlatformSession::class); config([ - 'smtp.platform_api_key' => 'test-smtp-key', - 'smtp.platform_api_url' => 'https://ladill.test/api/smtp', + 'mail.from.address' => 'events@ladill.com', + 'mail.from.name' => 'Ladill Events', 'events.service_api_keys.meet' => 'test-meet-key', ]); + Mail::fake(); + Http::fake([ config('billing.api_url').'/balance*' => Http::response(['balance_minor' => 50000]), - 'https://ladill.test/api/smtp/messages/send' => Http::response(['success' => true]), + config('billing.api_url').'/can-afford*' => Http::response(['affordable' => true]), + config('billing.api_url').'/debit' => Http::response(['success' => true]), ]); } @@ -99,8 +103,7 @@ class EventSpeakerInviteTest extends TestCase ->assertRedirect(route('speakers.show', $event)) ->assertSessionHas('success'); - Http::assertSent(fn ($request) => str_contains($request->url(), '/messages/send') - && $request['to'] === 'alex@example.com'); + Http::assertSent(fn ($request) => str_contains($request->url(), '/debit')); $event->refresh(); $speaker = $event->content()['speakers'][0]; @@ -163,7 +166,7 @@ class EventSpeakerInviteTest extends TestCase app(EventSpeakerInviteService::class)->inviteProgrammeHosts($programme, $owner); - Http::assertSent(fn ($request) => $request['to'] === 'pat@example.com'); + Http::assertSent(fn ($request) => str_contains($request->url(), '/debit')); $event->refresh(); $speaker = $event->content()['speakers'][0]; @@ -229,7 +232,7 @@ class EventSpeakerInviteTest extends TestCase public function test_manual_speaker_invite_reports_when_email_not_configured(): void { - config(['smtp.platform_api_key' => '']); + config(['mail.from.address' => '']); $owner = User::factory()->create(['public_id' => 'usr_sp_no_smtp']); @@ -293,6 +296,6 @@ class EventSpeakerInviteTest extends TestCase ->assertRedirect(route('speakers.show', $event)) ->assertSessionHas('success'); - Http::assertSent(fn ($request) => $request['to'] === 'pat@example.com'); + Http::assertSent(fn ($request) => str_contains($request->url(), '/debit')); } }