Add per-customer SMS and Bird Integrations for event messaging.
Deploy Ladill Events / deploy (push) Successful in 28s

Attendee and speaker comms use encrypted tenant relay credentials and skip platform-key fallback and Bird wallet double-debit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-12 17:05:54 +00:00
co-authored by Cursor
parent 13b2bd9702
commit 6e42cd1cb2
21 changed files with 1095 additions and 80 deletions
+32 -49
View File
@@ -2,31 +2,36 @@
namespace App\Services\Events;
use App\Services\Billing\BillingClient;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use App\Services\Messaging\CustomerEmailClient;
use App\Services\Messaging\MessagingCredentialsService;
/**
* Sends Ladill Events transactional email via the platform mailer
* (noreply@ladill.com same as CRM and other Ladill apps).
* Sends Ladill Events transactional email to attendees and speakers via the
* customer's Ladill Bird relay when configured in Integrations.
*/
class EventMailer
{
public const EMAIL_PRICE_MINOR = 1;
private ?string $lastError = null;
public function __construct(private readonly BillingClient $billing) {}
public function __construct(
private readonly MessagingCredentialsService $credentials,
private readonly CustomerEmailClient $customerEmail,
) {}
public function lastError(): ?string
{
return $this->lastError;
}
public function isConfiguredForOwner(string $ownerPublicId): bool
{
return $this->credentials->forOwner($ownerPublicId)->hasValidBird();
}
/** @deprecated Use isConfiguredForOwner() — attendee email requires per-account Bird credentials. */
public function isConfigured(): bool
{
return filter_var((string) config('mail.from.address'), FILTER_VALIDATE_EMAIL) !== false;
return false;
}
public function send(
@@ -40,54 +45,32 @@ class EventMailer
): bool {
$this->lastError = null;
if (! $this->isConfigured()) {
$this->lastError = 'Outbound email is not configured on this Events instance.';
$credential = $this->credentials->forOwner($ownerPublicId);
if (! $credential->hasValidBird()) {
$this->lastError = 'Connect Ladill Bird in Integrations before sending attendee email.';
return false;
}
if (! $this->billing->canAfford($ownerPublicId, self::EMAIL_PRICE_MINOR)) {
$this->lastError = 'Insufficient Ladill wallet balance. Add funds in Billing and try again.';
$apiKey = $credential->birdApiKey();
if (! $apiKey) {
$this->lastError = 'Bird credentials could not be decrypted. Reconnect Bird in Integrations.';
return false;
}
$reference = 'events_email:'.Str::uuid();
$sent = $this->customerEmail->send(
$apiKey,
(string) $credential->bird_from_email,
$credential->bird_from_name,
$to,
$subject,
$html,
$text,
);
try {
Mail::html($html, function ($message) use ($to, $subject, $text, $replyToEmail, $replyToName): void {
$message->to($to)
->subject($subject)
->from(
(string) config('mail.from.address'),
(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.';
if (! $sent) {
$this->lastError = $this->customerEmail->lastError() ?: 'The email could not be sent.';
return false;
}