Send Events mail from platform SMTP like Invoice, not owner Bird domain.
Deploy Ladill Events / deploy (push) Successful in 31s

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 <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-03 17:09:05 +00:00
co-authored by Cursor
parent 1db499ba2f
commit 0c5a1ddb23
8 changed files with 233 additions and 31 deletions
+10
View File
@@ -50,6 +50,16 @@ SMS_PLATFORM_API_URL=https://ladill.com/api
SMS_API_KEY_EVENTS= SMS_API_KEY_EVENTS=
SMS_DEFAULT_SENDER_ID=Ladill 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_PLATFORM_API_URL=https://ladill.com/api/smtp
SMTP_API_KEY_EVENTS= SMTP_API_KEY_EVENTS=
EVENTS_SMTP_FROM=events@ladill.com EVENTS_SMTP_FROM=events@ladill.com
+5 -3
View File
@@ -58,6 +58,8 @@ class EventCommsService
$programmeUrl = $programme?->publicUrl(); $programmeUrl = $programme?->publicUrl();
$joinUrl = $this->primaryJoinUrl($event); $joinUrl = $this->primaryJoinUrl($event);
$ownerRef = (string) $event->user->public_id; $ownerRef = (string) $event->user->public_id;
$organizerEmail = $event->user->email;
$organizerName = $event->user->name;
$emailed = 0; $emailed = 0;
$texted = 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 ($reg->attendee_email && in_array($mode, [self::MODE_PROGRAMME, self::MODE_BOTH], true) && $programmeUrl) {
if ($mode === self::MODE_BOTH) { 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 { } 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) { if ($sent) {
$emailed++; $emailed++;
} }
} elseif ($reg->attendee_email && $mode === self::MODE_JOIN && $joinUrl !== '') { } 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) { if ($sent) {
$emailed++; $emailed++;
} }
+43 -7
View File
@@ -2,16 +2,15 @@
namespace App\Services\Events; namespace App\Services\Events;
use App\Services\Billing\PlatformEmailClient;
use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\View;
class EventEmailService class EventEmailService
{ {
public function __construct(private readonly PlatformEmailClient $platform) {} public function __construct(private readonly EventMailer $mailer) {}
public function lastError(): ?string public function lastError(): ?string
{ {
return $this->platform->lastError(); return $this->mailer->lastError();
} }
public function sendProgrammeShare( public function sendProgrammeShare(
@@ -20,6 +19,8 @@ class EventEmailService
string $eventName, string $eventName,
string $programmeUrl, string $programmeUrl,
?string $attendeeName = null, ?string $attendeeName = null,
?string $organizerEmail = null,
?string $organizerName = null,
): bool { ): bool {
return $this->send( return $this->send(
$ownerPublicId, $ownerPublicId,
@@ -27,6 +28,9 @@ class EventEmailService
'Programme for '.$eventName, 'Programme for '.$eventName,
'mail.notifications.event-programme', 'mail.notifications.event-programme',
compact('eventName', 'programmeUrl', 'attendeeName'), compact('eventName', 'programmeUrl', 'attendeeName'),
$eventName,
$organizerEmail,
$organizerName,
); );
} }
@@ -36,6 +40,8 @@ class EventEmailService
string $eventName, string $eventName,
string $joinUrl, string $joinUrl,
?string $attendeeName = null, ?string $attendeeName = null,
?string $organizerEmail = null,
?string $organizerName = null,
): bool { ): bool {
return $this->send( return $this->send(
$ownerPublicId, $ownerPublicId,
@@ -43,6 +49,9 @@ class EventEmailService
'Join '.$eventName, 'Join '.$eventName,
'mail.notifications.event-join', 'mail.notifications.event-join',
compact('eventName', 'joinUrl', 'attendeeName'), compact('eventName', 'joinUrl', 'attendeeName'),
$eventName,
$organizerEmail,
$organizerName,
); );
} }
@@ -53,6 +62,8 @@ class EventEmailService
string $joinUrl, string $joinUrl,
string $programmeUrl, string $programmeUrl,
?string $attendeeName = null, ?string $attendeeName = null,
?string $organizerEmail = null,
?string $organizerName = null,
): bool { ): bool {
return $this->send( return $this->send(
$ownerPublicId, $ownerPublicId,
@@ -60,6 +71,9 @@ class EventEmailService
$eventName.' — programme & join link', $eventName.' — programme & join link',
'mail.notifications.event-join-programme', 'mail.notifications.event-join-programme',
compact('eventName', 'joinUrl', 'programmeUrl', 'attendeeName'), compact('eventName', 'joinUrl', 'programmeUrl', 'attendeeName'),
$eventName,
$organizerEmail,
$organizerName,
); );
} }
@@ -70,6 +84,8 @@ class EventEmailService
string $badgeCode, string $badgeCode,
?string $joinUrl = null, ?string $joinUrl = null,
?string $attendeeName = null, ?string $attendeeName = null,
?string $organizerEmail = null,
?string $organizerName = null,
): bool { ): bool {
return $this->send( return $this->send(
$ownerPublicId, $ownerPublicId,
@@ -77,6 +93,9 @@ class EventEmailService
'Registration confirmed — '.$eventName, 'Registration confirmed — '.$eventName,
'mail.notifications.event-registration-confirmed', 'mail.notifications.event-registration-confirmed',
compact('eventName', 'badgeCode', 'joinUrl', 'attendeeName'), compact('eventName', 'badgeCode', 'joinUrl', 'attendeeName'),
$eventName,
$organizerEmail,
$organizerName,
); );
} }
@@ -86,6 +105,8 @@ class EventEmailService
string $eventName, string $eventName,
string $portalUrl, string $portalUrl,
?string $speakerName = null, ?string $speakerName = null,
?string $organizerEmail = null,
?string $organizerName = null,
): bool { ): bool {
return $this->send( return $this->send(
$ownerPublicId, $ownerPublicId,
@@ -97,20 +118,35 @@ class EventEmailService
'portalUrl' => $portalUrl, 'portalUrl' => $portalUrl,
'speakerName' => $speakerName, 'speakerName' => $speakerName,
], ],
$eventName,
$organizerEmail,
$organizerName,
); );
} }
/** @param array<string, mixed> $viewData */ /** @param array<string, mixed> $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(); $html = View::make($view, $viewData)->render();
$text = strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $html));
return $this->platform->send( return $this->mailer->send(
$ownerPublicId, $ownerPublicId,
$to, $to,
$subject, $subject,
$html, $html,
strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $html)), $text,
$fromDisplayName,
$replyToEmail,
$replyToName,
); );
} }
} }
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace App\Services\Events;
use App\Services\Billing\BillingClient;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
/**
* Sends Ladill Events transactional email from the platform mailer (like Invoice),
* not the event owner's Bird SMTP domain. Reply-To is set to the organiser.
*/
class EventMailer
{
public const EMAIL_PRICE_MINOR = 1;
private ?string $lastError = null;
public function __construct(private readonly BillingClient $billing) {}
public function lastError(): ?string
{
return $this->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;
}
}
@@ -276,6 +276,8 @@ class EventRegistrationService
$registration->badge_code, $registration->badge_code,
$joinUrl !== '' ? $joinUrl : null, $joinUrl !== '' ? $joinUrl : null,
$registration->attendee_name, $registration->attendee_name,
$registration->qrCode?->user?->email,
$registration->qrCode?->user?->name,
); );
} }
@@ -226,7 +226,7 @@ class EventSpeakerInviteService
return ['ok' => false, 'error' => 'Could not resolve the event owner account for sending email.']; 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.']; return ['ok' => false, 'error' => 'Outbound email is not configured on this Events instance.'];
} }
@@ -244,6 +244,8 @@ class EventSpeakerInviteService
$eventName, $eventName,
$portalUrl, $portalUrl,
$speaker['name'] ?? null, $speaker['name'] ?? null,
$owner->email,
$owner->name,
); );
if (! $sent) { if (! $sent) {
@@ -1,12 +1,61 @@
<!DOCTYPE html> @extends('mail.notifications.layout')
<html>
<body style="font-family: sans-serif; color: #1e293b; line-height: 1.5;"> @section('email-header')
@if($speakerName) @include('mail.partials.brand-header', ['brand' => 'events'])
<p>Hi {{ $speakerName }},</p> @endsection
@endif
<p>You have been invited to speak at <strong>{{ $eventName }}</strong>.</p> @section('email-footer')
<p>Your speaker page has the programme, your session times, and links to join the virtual stage when it is time:</p> @include('mail.partials.brand-footer', ['brand' => 'events'])
<p><a href="{{ $portalUrl }}">Open your speaker page</a></p> @endsection
<p style="color: #64748b; font-size: 13px;">No attendee registration is required use the join links on that page to enter the speaker waiting area.</p>
</body> @section('content')
</html> <div class="highlight-box">
<p class="highlight-box-text">Speaker invitation</p>
<p class="highlight-box-subtext">{{ $eventName }}</p>
</div>
<h1 class="email-title">
You're invited to speak{{ $speakerName ? ', '.explode(' ', $speakerName)[0] : '' }}
</h1>
<p class="email-text">
The organiser of <strong>{{ $eventName }}</strong> has added you to the speaker roster.
Your speaker page includes the programme, your session times, and links to join the virtual stage.
</p>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin:24px 0;">
<tr>
<td align="center">
<a href="{{ $portalUrl }}" class="email-button" style="display:inline-block;background:#4f46e5;color:#ffffff;text-decoration:none;padding:12px 28px;border-radius:10px;font-size:14px;font-weight:600;">
Open your speaker page
</a>
</td>
</tr>
</table>
<div class="email-details">
<p class="email-details-title">What to expect</p>
<div class="checklist-item">
<div class="checklist-icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
</div>
<div class="checklist-content">
<p class="checklist-title">No attendee registration</p>
<p class="checklist-desc">Speakers skip the public registration flow.</p>
</div>
</div>
<div class="checklist-item">
<div class="checklist-icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
</div>
<div class="checklist-content">
<p class="checklist-title">Join from your speaker page</p>
<p class="checklist-desc">Use the join links when your session is scheduled to open the speaker waiting area.</p>
</div>
</div>
</div>
<p class="email-text-sm">
Or open this link: <a href="{{ $portalUrl }}" style="color:#4f46e5;">{{ $portalUrl }}</a>
</p>
@endsection
+11 -8
View File
@@ -7,6 +7,7 @@ use App\Models\User;
use App\Services\Events\EventSpeakerInviteService; use App\Services\Events\EventSpeakerInviteService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase; use Tests\TestCase;
class EventSpeakerInviteTest extends TestCase class EventSpeakerInviteTest extends TestCase
@@ -19,14 +20,17 @@ class EventSpeakerInviteTest extends TestCase
$this->withoutMiddleware(\App\Http\Middleware\EnsurePlatformSession::class); $this->withoutMiddleware(\App\Http\Middleware\EnsurePlatformSession::class);
config([ config([
'smtp.platform_api_key' => 'test-smtp-key', 'mail.from.address' => 'events@ladill.com',
'smtp.platform_api_url' => 'https://ladill.test/api/smtp', 'mail.from.name' => 'Ladill Events',
'events.service_api_keys.meet' => 'test-meet-key', 'events.service_api_keys.meet' => 'test-meet-key',
]); ]);
Mail::fake();
Http::fake([ Http::fake([
config('billing.api_url').'/balance*' => Http::response(['balance_minor' => 50000]), 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)) ->assertRedirect(route('speakers.show', $event))
->assertSessionHas('success'); ->assertSessionHas('success');
Http::assertSent(fn ($request) => str_contains($request->url(), '/messages/send') Http::assertSent(fn ($request) => str_contains($request->url(), '/debit'));
&& $request['to'] === 'alex@example.com');
$event->refresh(); $event->refresh();
$speaker = $event->content()['speakers'][0]; $speaker = $event->content()['speakers'][0];
@@ -163,7 +166,7 @@ class EventSpeakerInviteTest extends TestCase
app(EventSpeakerInviteService::class)->inviteProgrammeHosts($programme, $owner); 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(); $event->refresh();
$speaker = $event->content()['speakers'][0]; $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 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']); $owner = User::factory()->create(['public_id' => 'usr_sp_no_smtp']);
@@ -293,6 +296,6 @@ class EventSpeakerInviteTest extends TestCase
->assertRedirect(route('speakers.show', $event)) ->assertRedirect(route('speakers.show', $event))
->assertSessionHas('success'); ->assertSessionHas('success');
Http::assertSent(fn ($request) => $request['to'] === 'pat@example.com'); Http::assertSent(fn ($request) => str_contains($request->url(), '/debit'));
} }
} }