diff --git a/.env.example b/.env.example index a82d56d..fd77644 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,7 @@ COMMANDS_LOG_LEVEL=info COMMANDS_LOG_DAYS=30 EMAILS_LOG_LEVEL=info EMAILS_LOG_DAYS=30 +EMAIL_DELIVERY_LEASE_SECONDS=300 DB_CONNECTION=mysql DB_HOST=127.0.0.1 diff --git a/app/Domains/Notification/Models/EmailDelivery.php b/app/Domains/Notification/Models/EmailDelivery.php new file mode 100644 index 0000000..8b16a6d --- /dev/null +++ b/app/Domains/Notification/Models/EmailDelivery.php @@ -0,0 +1,44 @@ + 'integer', + 'context' => 'array', + 'claimed_at' => 'datetime', + 'lease_expires_at' => 'datetime', + 'sent_at' => 'datetime', + 'failed_at' => 'datetime', + ]; + } +} diff --git a/app/Domains/Notification/Models/EventDateNotificationDelivery.php b/app/Domains/Notification/Models/EventDateNotificationDelivery.php deleted file mode 100644 index 33bd028..0000000 --- a/app/Domains/Notification/Models/EventDateNotificationDelivery.php +++ /dev/null @@ -1,28 +0,0 @@ - 'integer', - 'destination_event_date_id' => 'integer', - 'purchase_id' => 'integer', - 'sent_at' => 'datetime', - ]; - } -} diff --git a/app/Domains/Notification/Services/IdempotentEmailDeliveryService.php b/app/Domains/Notification/Services/IdempotentEmailDeliveryService.php new file mode 100644 index 0000000..48e8c37 --- /dev/null +++ b/app/Domains/Notification/Services/IdempotentEmailDeliveryService.php @@ -0,0 +1,112 @@ + $context + * @param Closure(): void $send + */ + public function sendOnce( + string $key, + string $type, + ?string $tenantCode, + array $context, + string $recipient, + Closure $send, + ): bool { + $now = now(); + + EmailDelivery::query()->insertOrIgnore([ + 'idempotency_key' => $key, + 'email_type' => $type, + 'tenant_code' => $tenantCode, + 'status' => EmailDelivery::STATUS_PENDING, + 'attempts' => 0, + 'context' => json_encode($context, JSON_THROW_ON_ERROR), + 'recipient_fingerprint' => $this->recipientFingerprint($recipient), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $claimToken = (string) Str::uuid(); + $leaseExpiresAt = $now->copy()->addSeconds( + max(1, (int) config('mail.delivery_lease_seconds', 300)), + ); + + $claimed = EmailDelivery::query() + ->where('idempotency_key', $key) + ->where(function ($query) use ($now): void { + $query->whereIn('status', [ + EmailDelivery::STATUS_PENDING, + EmailDelivery::STATUS_FAILED, + ])->orWhere(function ($query) use ($now): void { + $query->where('status', EmailDelivery::STATUS_PROCESSING) + ->where('lease_expires_at', '<=', $now); + }); + }) + ->update([ + 'status' => EmailDelivery::STATUS_PROCESSING, + 'attempts' => new Expression('attempts + 1'), + 'context' => json_encode($context, JSON_THROW_ON_ERROR), + 'recipient_fingerprint' => $this->recipientFingerprint($recipient), + 'claim_token' => $claimToken, + 'claimed_at' => $now, + 'lease_expires_at' => $leaseExpiresAt, + 'failed_at' => null, + 'last_error' => null, + 'updated_at' => $now, + ]) === 1; + + if (! $claimed) { + return false; + } + + try { + $send(); + + EmailDelivery::query() + ->where('idempotency_key', $key) + ->where('claim_token', $claimToken) + ->update([ + 'status' => EmailDelivery::STATUS_SENT, + 'claim_token' => null, + 'lease_expires_at' => null, + 'sent_at' => now(), + 'updated_at' => now(), + ]); + } catch (Throwable $exception) { + EmailDelivery::query() + ->where('idempotency_key', $key) + ->where('claim_token', $claimToken) + ->update([ + 'status' => EmailDelivery::STATUS_FAILED, + 'claim_token' => null, + 'lease_expires_at' => null, + 'failed_at' => now(), + 'last_error' => Str::limit($exception::class, 2000, ''), + 'updated_at' => now(), + ]); + + throw $exception; + } + + return true; + } + + private function recipientFingerprint(string $recipient): string + { + return hash_hmac( + 'sha256', + mb_strtolower(trim($recipient)), + (string) config('app.key'), + ); + } +} diff --git a/app/Domains/Notification/Services/NotificationMailService.php b/app/Domains/Notification/Services/NotificationMailService.php index efbdabb..057810a 100644 --- a/app/Domains/Notification/Services/NotificationMailService.php +++ b/app/Domains/Notification/Services/NotificationMailService.php @@ -6,7 +6,6 @@ use App\Domains\Auth\Models\ResetPasswordAttempt; use App\Domains\Auth\Models\User; use App\Domains\Integration\Services\MailService; use App\Domains\Notification\Events\PasswordResetRequested; -use App\Domains\Notification\Models\EventDateNotificationDelivery; use App\Domains\Purchase\Models\Purchase; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; @@ -23,32 +22,42 @@ class NotificationMailService public function __construct( private readonly MailService $mailService, private readonly TicketPdfService $ticketPdfService, + private readonly IdempotentEmailDeliveryService $emailDeliveryService, ) {} public function sendWelcome(int $userId, string $tenantCode): void { - $this->sendLogged('welcome', [ + $context = [ 'user_id' => $userId, 'tenant_code' => $tenantCode, - ], function () use ($userId, $tenantCode): array { - $tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail(); - $user = User::query()->findOrFail($userId); - $brand = $tenant->websiteType ?? $tenant; - $tenantUrl = 'https://'.$tenant->dominio.$tenant->base_path; + ]; + $tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail(); + $user = User::query()->findOrFail($userId); - $this->mailService - ->forTenant($tenantCode) - ->send( - $user->email, - "Bienvenido a {$brand->nombre}", - view('mail.notifications.welcome', compact('brand', 'user', 'tenantUrl'))->render(), - $brand, - ); + $this->sendIdempotently( + "welcome:{$tenantCode}:{$userId}", + 'welcome', + $tenantCode, + $context, + $user->email, + function () use ($user, $tenant, $tenantCode): array { + $brand = $tenant->websiteType ?? $tenant; + $tenantUrl = 'https://'.$tenant->dominio.$tenant->base_path; - return [ - 'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type', - ]; - }); + $this->mailService + ->forTenant($tenantCode) + ->send( + $user->email, + "Bienvenido a {$brand->nombre}", + view('mail.notifications.welcome', compact('brand', 'user', 'tenantUrl'))->render(), + $brand, + ); + + return [ + 'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type', + ]; + }, + ); } public function sendPasswordResetCode( @@ -62,128 +71,149 @@ class NotificationMailService 'channel' => $channel, ]; - $this->sendLogged('password_reset', $context, function () use ($attemptId, $tenantCode, $channel, $context): ?array { - $tenant = Tenant::query() - ->with('websiteType') - ->where('codigo', $tenantCode) - ->firstOrFail(); - $attempt = ResetPasswordAttempt::query() - ->with('user') - ->findOrFail($attemptId); + $tenant = Tenant::query() + ->with('websiteType') + ->where('codigo', $tenantCode) + ->firstOrFail(); + $attempt = ResetPasswordAttempt::query() + ->with('user') + ->findOrFail($attemptId); - if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) { - $this->logSkipped('password_reset', array_merge($context, [ - 'reason' => 'attempt_not_pending', - 'attempt_status' => $attempt->status, - 'user_id' => $attempt->user_id, - ])); - - return null; - } - - $recoveryDomain = match ($channel) { - PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio, - PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain, - default => $tenant->dominio, - }; - $recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT - && $tenant->base_path !== '/' - ? $tenant->base_path - : ''; - $recoveryQuery = ['email' => $attempt->user->email]; - if ( - $channel === PasswordResetRequested::CHANNEL_SCANNER - && $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED - ) { - $recoveryQuery['code'] = $attempt->codigo; - } - $recoveryUrl = $recoveryDomain === null - ? null - : 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery); - $brand = $tenant->websiteType ?? $tenant; - - [$subject, $template] = match ($attempt->reason) { - ResetPasswordAttempt::REASON_STAFF_CREATED => [ - 'Tu cuenta de escáner está lista', 'scanner-created', - ], - ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED => [ - 'Tu cuenta de administrador está lista', 'administrator-created', - ], - ResetPasswordAttempt::REASON_ACCOUNT_LOCKED => [ - 'Desbloqueá tu cuenta', 'account-locked', - ], - default => ['Código para recuperar tu contraseña', 'password-reset'], - }; - - $this->mailService - ->forTenant($tenantCode) - ->send( - $attempt->user->email, - "{$subject} - {$brand->nombre}", - view("mail.notifications.{$template}", [ - 'attempt' => $attempt, - 'recoveryUrl' => $recoveryUrl, - 'brand' => $brand, - ])->render(), - $brand, - ); - - return [ + if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) { + $this->logSkipped('password_reset', array_merge($context, [ + 'reason' => 'attempt_not_pending', + 'attempt_status' => $attempt->status, 'user_id' => $attempt->user_id, - 'recovery_domain_available' => $recoveryDomain !== null, - ]; - }); + ])); + + return; + } + + $this->sendIdempotently( + "password-reset:{$attemptId}", + 'password_reset', + $tenantCode, + $context, + $attempt->user->email, + function () use ($attempt, $tenant, $tenantCode, $channel): array { + $recoveryDomain = match ($channel) { + PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio, + PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain, + default => $tenant->dominio, + }; + $recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT + && $tenant->base_path !== '/' + ? $tenant->base_path + : ''; + $recoveryQuery = ['email' => $attempt->user->email]; + if ( + $channel === PasswordResetRequested::CHANNEL_SCANNER + && $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED + ) { + $recoveryQuery['code'] = $attempt->codigo; + } + $recoveryUrl = $recoveryDomain === null + ? null + : 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery); + $brand = $tenant->websiteType ?? $tenant; + + [$subject, $template] = match ($attempt->reason) { + ResetPasswordAttempt::REASON_STAFF_CREATED => [ + 'Tu cuenta de escáner está lista', 'scanner-created', + ], + ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED => [ + 'Tu cuenta de administrador está lista', 'administrator-created', + ], + ResetPasswordAttempt::REASON_ACCOUNT_LOCKED => [ + 'Desbloqueá tu cuenta', 'account-locked', + ], + default => ['Código para recuperar tu contraseña', 'password-reset'], + }; + + $this->mailService + ->forTenant($tenantCode) + ->send( + $attempt->user->email, + "{$subject} - {$brand->nombre}", + view("mail.notifications.{$template}", [ + 'attempt' => $attempt, + 'recoveryUrl' => $recoveryUrl, + 'brand' => $brand, + ])->render(), + $brand, + ); + + return [ + 'user_id' => $attempt->user_id, + 'recovery_domain_available' => $recoveryDomain !== null, + ]; + }, + ); } public function sendPurchaseConfirmed(int $purchaseId): void { $context = ['purchase_id' => $purchaseId]; - $this->sendLogged('purchase_confirmed', $context, function () use ($purchaseId, $context): ?array { - $purchase = Purchase::query() - ->with(['tenant', 'user', 'items']) - ->find($purchaseId); + $purchase = Purchase::query() + ->with(['tenant', 'user', 'items']) + ->find($purchaseId); - if ($purchase === null) { - $this->logSkipped('purchase_confirmed', array_merge($context, [ - 'reason' => 'purchase_not_found', - 'missing_model' => Purchase::class, - ])); + if ($purchase === null) { + $this->logSkipped('purchase_confirmed', array_merge($context, [ + 'reason' => 'purchase_not_found', + 'missing_model' => Purchase::class, + ])); - return null; - } + return; + } - /** @var Collection $tickets */ - $tickets = $purchase->tickets() - ->where('tenant_code', $purchase->tenant_codigo) - ->with(TicketPresentationResolver::RELATIONS) - ->get(); - $attachments = $tickets->isEmpty() - ? [] - : [[ - 'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets), - 'name' => $this->ticketPdfService->filename($tickets), - 'mime' => 'application/pdf', - ]]; + $recipient = $this->recipientFor($purchase); + if ($recipient === '') { + $this->logSkipped('purchase_confirmed', array_merge($context, ['reason' => 'missing_recipient'])); - $this->mailService - ->forTenant($purchase->tenant_codigo) - ->send( - $this->recipientFor($purchase), - "Compra confirmada - Compra #{$purchase->getKey()}", - view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(), - attachments: $attachments, - ); + return; + } - return [ - 'tenant_code' => $purchase->tenant_codigo, - 'user_id' => $purchase->user_id, - 'purchase_status' => $purchase->status, - 'purchase_item_count' => $purchase->items->count(), - 'ticket_count' => $tickets->count(), - 'ticket_ids' => $tickets->modelKeys(), - ]; - }); + $this->sendIdempotently( + "purchase-confirmed:{$purchaseId}", + 'purchase_confirmed', + $purchase->tenant_codigo, + $context, + $recipient, + function () use ($purchase, $recipient): array { + /** @var Collection $tickets */ + $tickets = $purchase->tickets() + ->where('tenant_code', $purchase->tenant_codigo) + ->with(TicketPresentationResolver::RELATIONS) + ->get(); + $attachments = $tickets->isEmpty() + ? [] + : [[ + 'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets), + 'name' => $this->ticketPdfService->filename($tickets), + 'mime' => 'application/pdf', + ]]; + + $this->mailService + ->forTenant($purchase->tenant_codigo) + ->send( + $recipient, + "Compra confirmada - Compra #{$purchase->getKey()}", + view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(), + attachments: $attachments, + ); + + return [ + 'tenant_code' => $purchase->tenant_codigo, + 'user_id' => $purchase->user_id, + 'purchase_status' => $purchase->status, + 'purchase_item_count' => $purchase->items->count(), + 'ticket_count' => $tickets->count(), + 'ticket_ids' => $tickets->modelKeys(), + ]; + }, + ); } /** @@ -247,23 +277,13 @@ class NotificationMailService } $deliveryKey = "event-date-rescheduled:{$sourceEventDateId}:{$destinationEventDateId}:{$purchaseId}"; - if (! $this->claimDelivery( + $this->sendIdempotently( $deliveryKey, 'event_date_rescheduled', $tenantCode, - $sourceEventDateId, - $destinationEventDateId, - $purchaseId, - )) { - $this->logSkipped('event_date_rescheduled', array_merge($context, [ - 'reason' => 'already_sent', - ])); - - continue; - } - - try { - $this->sendLogged('event_date_rescheduled', $context, function () use ( + $context, + $recipient, + function () use ( $tenantCode, $purchase, $recipient, @@ -285,13 +305,8 @@ class NotificationMailService ); return ['ticket_count' => $tickets->count()]; - }); - $this->markDeliverySent($deliveryKey); - } catch (Throwable $exception) { - $this->releaseDelivery($deliveryKey); - - throw $exception; - } + }, + ); } } @@ -356,21 +371,6 @@ class NotificationMailService } $deliveryKey = "event-date-suspended:{$eventDateId}:{$purchaseId}"; - if (! $this->claimDelivery( - $deliveryKey, - 'event_date_suspended', - $tenantCode, - $eventDateId, - null, - $purchaseId, - )) { - $this->logSkipped('event_date_suspended', array_merge($context, [ - 'reason' => 'already_sent', - ])); - - continue; - } - $disabledTickets = $tickets ->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_DISABLED) ->values(); @@ -378,8 +378,13 @@ class NotificationMailService ->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_ACTIVE) ->values(); - try { - $this->sendLogged('event_date_suspended', $context, function () use ( + $this->sendIdempotently( + $deliveryKey, + 'event_date_suspended', + $tenantCode, + $context, + $recipient, + function () use ( $tenantCode, $purchase, $recipient, @@ -405,13 +410,8 @@ class NotificationMailService 'disabled_ticket_ids' => $disabledTickets->modelKeys(), 'active_ticket_ids' => $activeTickets->modelKeys(), ]; - }); - $this->markDeliverySent($deliveryKey); - } catch (Throwable $exception) { - $this->releaseDelivery($deliveryKey); - - throw $exception; - } + }, + ); } } @@ -423,45 +423,39 @@ class NotificationMailService ->find($purchaseId); } - private function claimDelivery( - string $deliveryKey, - string $type, - string $tenantCode, - int $eventDateId, - ?int $destinationEventDateId, - int $purchaseId, - ): bool { - return EventDateNotificationDelivery::query()->insertOrIgnore([ - 'notification_key' => $deliveryKey, - 'notification_type' => $type, - 'tenant_code' => $tenantCode, - 'event_date_id' => $eventDateId, - 'destination_event_date_id' => $destinationEventDateId, - 'purchase_id' => $purchaseId, - 'created_at' => now(), - 'updated_at' => now(), - ]) === 1; - } - - private function markDeliverySent(string $deliveryKey): void - { - EventDateNotificationDelivery::query() - ->where('notification_key', $deliveryKey) - ->update(['sent_at' => now()]); - } - - private function releaseDelivery(string $deliveryKey): void - { - EventDateNotificationDelivery::query() - ->where('notification_key', $deliveryKey) - ->delete(); - } - private function recipientFor(Purchase $purchase): string { return (string) ($purchase->email ?: $purchase->user?->email); } + /** + * @param array $context + * @param Closure(): array $send + */ + private function sendIdempotently( + string $key, + string $emailType, + ?string $tenantCode, + array $context, + string $recipient, + Closure $send, + ): void { + $sent = $this->emailDeliveryService->sendOnce( + $key, + $emailType, + $tenantCode, + $context, + $recipient, + function () use ($emailType, $context, $send): void { + $this->sendLogged($emailType, $context, $send); + }, + ); + + if (! $sent) { + $this->logSkipped($emailType, array_merge($context, ['reason' => 'already_claimed'])); + } + } + /** * @param array $context * @param Closure(): (array|null) $send diff --git a/app/Domains/Notification/documentacion/README.md b/app/Domains/Notification/documentacion/README.md index 467c0dd..683402a 100644 --- a/app/Domains/Notification/documentacion/README.md +++ b/app/Domains/Notification/documentacion/README.md @@ -12,7 +12,19 @@ Orquesta notificaciones de negocio por correo a partir de eventos de otros domin ## Componentes -Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail` y `SendPurchaseConfirmedEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`. +Los listeners delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`. + +`IdempotentEmailDeliveryService` coordina los envíos automáticos mediante la tabla +`email_deliveries`. Cada correo utiliza una clave de negocio única: + +- bienvenida: `welcome:{tenant_code}:{user_id}`; +- recuperación: `password-reset:{attempt_id}`; +- compra confirmada: `purchase-confirmed:{purchase_id}`; +- reprogramación: `event-date-rescheduled:{source_event_date_id}:{destination_event_date_id}:{purchase_id}`; +- suspensión: `event-date-suspended:{event_date_id}:{purchase_id}`. + +Los correos de prueba y de validación de una integración SMTP no usan esta capa, +porque su reenvío explícito es parte de su comportamiento esperado. ## API y dependencias @@ -25,3 +37,12 @@ No expone rutas HTTP. Consume datos de `Auth`, `Tenant`, `Purchase` y `Ticket`, - Los correos de cuenta (bienvenida y recuperación de contraseña) usan la identidad visual del `WebsiteType` asociado al tenant, con fallback al tenant si no tiene uno configurado. - El correo transaccional de compra confirmada usa la identidad visual del tenant y adjunta un único PDF cuando la compra generó tickets. - Los handlers deben permanecer idempotentes o tolerantes a reintentos de cola. +- Una entrega queda en estado `processing` mientras un worker posee su claim. Si + el worker se interrumpe, el claim vence según `EMAIL_DELIVERY_LEASE_SECONDS` y + otro intento puede recuperarlo. +- Los fallos quedan registrados como `failed` y pueden ser retomados por los + reintentos de la cola. Los envíos exitosos permanecen como `sent` y las llamadas + posteriores con la misma clave no vuelven a enviar el correo. +- SMTP no ofrece una confirmación transaccional junto con la base de datos. Una + interrupción ocurrida después de entregar el correo y antes de registrar + `sent` puede producir un duplicado excepcional al recuperar el claim. diff --git a/config/mail.php b/config/mail.php index e32e88d..90fb4f5 100644 --- a/config/mail.php +++ b/config/mail.php @@ -16,6 +16,8 @@ return [ 'default' => env('MAIL_MAILER', 'log'), + 'delivery_lease_seconds' => (int) env('EMAIL_DELIVERY_LEASE_SECONDS', 300), + /* |-------------------------------------------------------------------------- | Mailer Configurations diff --git a/database/migrations/2026_09_14_000000_create_email_deliveries_table.php b/database/migrations/2026_09_14_000000_create_email_deliveries_table.php new file mode 100644 index 0000000..eb0cb98 --- /dev/null +++ b/database/migrations/2026_09_14_000000_create_email_deliveries_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('idempotency_key')->unique(); + $table->string('email_type')->index(); + $table->string('tenant_code')->nullable()->index(); + $table->string('status')->index(); + $table->unsignedInteger('attempts')->default(0); + $table->json('context')->nullable(); + $table->string('recipient_fingerprint', 64)->nullable(); + $table->uuid('claim_token')->nullable()->index(); + $table->timestamp('claimed_at')->nullable(); + $table->timestamp('lease_expires_at')->nullable()->index(); + $table->timestamp('sent_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->text('last_error')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('email_deliveries'); + } +}; diff --git a/database/migrations/2026_09_14_000000_create_event_date_notification_deliveries_table.php b/database/migrations/2026_09_14_000000_create_event_date_notification_deliveries_table.php deleted file mode 100644 index e8070ad..0000000 --- a/database/migrations/2026_09_14_000000_create_event_date_notification_deliveries_table.php +++ /dev/null @@ -1,28 +0,0 @@ -id(); - $table->string('notification_key')->unique(); - $table->string('notification_type'); - $table->string('tenant_code'); - $table->unsignedBigInteger('event_date_id'); - $table->unsignedBigInteger('destination_event_date_id')->nullable(); - $table->unsignedBigInteger('purchase_id'); - $table->timestamp('sent_at')->nullable(); - $table->timestamps(); - }); - } - - public function down(): void - { - Schema::dropIfExists('event_date_notification_deliveries'); - } -}; diff --git a/tests/Feature/Notification/IdempotentEmailDeliveryServiceTest.php b/tests/Feature/Notification/IdempotentEmailDeliveryServiceTest.php new file mode 100644 index 0000000..5b666a1 --- /dev/null +++ b/tests/Feature/Notification/IdempotentEmailDeliveryServiceTest.php @@ -0,0 +1,136 @@ +sendOnce( + 'welcome:tenant:10', + 'welcome', + 'tenant', + ['user_id' => 10], + 'ada@example.com', + function () use (&$calls): void { + $calls++; + }, + ); + $second = $service->sendOnce( + 'welcome:tenant:10', + 'welcome', + 'tenant', + ['user_id' => 10], + 'ada@example.com', + function () use (&$calls): void { + $calls++; + }, + ); + + $this->assertTrue($first); + $this->assertFalse($second); + $this->assertSame(1, $calls); + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => 'welcome:tenant:10', + 'status' => EmailDelivery::STATUS_SENT, + 'attempts' => 1, + ]); + } + + public function test_it_records_a_failure_and_allows_a_retry(): void + { + $service = app(IdempotentEmailDeliveryService::class); + + try { + $service->sendOnce( + 'purchase-confirmed:20', + 'purchase_confirmed', + 'tenant', + ['purchase_id' => 20], + 'buyer@example.com', + fn () => throw new RuntimeException('Sensitive SMTP detail'), + ); + $this->fail('The delivery exception was not rethrown.'); + } catch (RuntimeException) { + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => 'purchase-confirmed:20', + 'status' => EmailDelivery::STATUS_FAILED, + 'attempts' => 1, + 'last_error' => RuntimeException::class, + ]); + } + + $sent = $service->sendOnce( + 'purchase-confirmed:20', + 'purchase_confirmed', + 'tenant', + ['purchase_id' => 20], + 'buyer@example.com', + static function (): void {}, + ); + + $this->assertTrue($sent); + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => 'purchase-confirmed:20', + 'status' => EmailDelivery::STATUS_SENT, + 'attempts' => 2, + 'last_error' => null, + ]); + } + + public function test_it_recovers_an_expired_claim_but_not_an_active_one(): void + { + config(['mail.delivery_lease_seconds' => 300]); + $service = app(IdempotentEmailDeliveryService::class); + $delivery = EmailDelivery::query()->create([ + 'idempotency_key' => 'password-reset:30', + 'email_type' => 'password_reset', + 'tenant_code' => 'tenant', + 'status' => EmailDelivery::STATUS_PROCESSING, + 'attempts' => 1, + 'context' => ['attempt_id' => 30], + 'recipient_fingerprint' => str_repeat('a', 64), + 'claim_token' => fake()->uuid(), + 'claimed_at' => now(), + 'lease_expires_at' => now()->addMinute(), + ]); + + $activeClaim = $service->sendOnce( + $delivery->idempotency_key, + $delivery->email_type, + $delivery->tenant_code, + $delivery->context, + 'ada@example.com', + static function (): void {}, + ); + $this->assertFalse($activeClaim); + + $delivery->update(['lease_expires_at' => now()->subSecond()]); + $expiredClaim = $service->sendOnce( + $delivery->idempotency_key, + $delivery->email_type, + $delivery->tenant_code, + $delivery->context, + 'ada@example.com', + static function (): void {}, + ); + + $this->assertTrue($expiredClaim); + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => 'password-reset:30', + 'status' => EmailDelivery::STATUS_SENT, + 'attempts' => 2, + ]); + } +} diff --git a/tests/Feature/Notification/NotificationMailServiceTest.php b/tests/Feature/Notification/NotificationMailServiceTest.php index f305d8d..a642f03 100644 --- a/tests/Feature/Notification/NotificationMailServiceTest.php +++ b/tests/Feature/Notification/NotificationMailServiceTest.php @@ -14,6 +14,7 @@ use App\Domains\Purchase\Models\Purchase; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Ticket\Models\Ticket; +use Barryvdh\DomPDF\ServiceProvider as DomPdfServiceProvider; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Mail\Mailable; use Illuminate\Support\Facades\Mail; @@ -31,6 +32,7 @@ class NotificationMailServiceTest extends TestCase { parent::setUp(); + $this->app->register(DomPdfServiceProvider::class); Mail::fake(); Integration::query()->create([ 'integration_code' => 'email', @@ -75,6 +77,9 @@ class NotificationMailServiceTest extends TestCase $this->useWebsiteTypeBranding(); app(NotificationMailService::class)->sendWelcome($this->user->id, $this->tenant->codigo); + app(NotificationMailService::class)->sendWelcome($this->user->id, $this->tenant->codigo); + + Mail::assertSent(Mailable::class, 1); Mail::assertSent(Mailable::class, function (Mailable $mail): bool { $mail->assertTo('ada@example.com'); @@ -98,6 +103,12 @@ class NotificationMailServiceTest extends TestCase $attempt->id, $this->tenant->codigo, ); + app(NotificationMailService::class)->sendPasswordResetCode( + $attempt->id, + $this->tenant->codigo, + ); + + Mail::assertSent(Mailable::class, 1); Mail::assertSent(Mailable::class, function (Mailable $mail): bool { $mail->assertTo('ada@example.com'); @@ -113,6 +124,27 @@ class NotificationMailServiceTest extends TestCase }); } + public function test_password_reset_idempotency_is_scoped_to_each_attempt(): void + { + $firstAttempt = $this->user->resetPasswordAttempts()->create(['codigo' => '0123']); + $secondAttempt = $this->user->resetPasswordAttempts()->create(['codigo' => '4567']); + $service = app(NotificationMailService::class); + + $service->sendPasswordResetCode($firstAttempt->id, $this->tenant->codigo); + $service->sendPasswordResetCode($firstAttempt->id, $this->tenant->codigo); + $service->sendPasswordResetCode($secondAttempt->id, $this->tenant->codigo); + + Mail::assertSent(Mailable::class, 2); + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => "password-reset:{$firstAttempt->id}", + 'status' => 'sent', + ]); + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => "password-reset:{$secondAttempt->id}", + 'status' => 'sent', + ]); + } + public function test_it_links_scanner_password_resets_to_the_scanner_domain(): void { $websiteType = WebsiteType::query()->create([ @@ -261,6 +293,9 @@ class NotificationMailServiceTest extends TestCase ]); app(NotificationMailService::class)->sendPurchaseConfirmed($purchase->id); + app(NotificationMailService::class)->sendPurchaseConfirmed($purchase->id); + + Mail::assertSent(Mailable::class, 1); Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase): bool { return $mail->subject === "Compra confirmada - Compra #{$purchase->id}" @@ -323,9 +358,10 @@ class NotificationMailServiceTest extends TestCase && str_contains($mail->render(), '#'.$firstTicket->id) && str_contains($mail->render(), '#'.$secondTicket->id); }); - $this->assertDatabaseHas('event_date_notification_deliveries', [ - 'notification_key' => "event-date-rescheduled:10:20:{$purchase->id}", - 'purchase_id' => $purchase->id, + $this->assertDatabaseHas('email_deliveries', [ + 'idempotency_key' => "event-date-rescheduled:10:20:{$purchase->id}", + 'email_type' => 'event_date_rescheduled', + 'status' => 'sent', ]); } @@ -362,6 +398,17 @@ class NotificationMailServiceTest extends TestCase 'ticket_ids' => [$disabledTicket->id, $activeTicket->id], ]], ); + app(NotificationMailService::class)->sendEventDateSuspended( + $this->tenant->codigo, + 10, + '09/10/2027', + [[ + 'purchase_id' => $purchase->id, + 'ticket_ids' => [$disabledTicket->id, $activeTicket->id], + ]], + ); + + Mail::assertSent(Mailable::class, 1); Mail::assertSent(Mailable::class, function (Mailable $mail) use ($disabledTicket, $activeTicket): bool { $mail->assertTo('ada@example.com'); diff --git a/tests/Unit/Notification/NotificationMailServiceLoggingTest.php b/tests/Unit/Notification/NotificationMailServiceLoggingTest.php index 6733354..9f69677 100644 --- a/tests/Unit/Notification/NotificationMailServiceLoggingTest.php +++ b/tests/Unit/Notification/NotificationMailServiceLoggingTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\Notification; use App\Domains\Integration\Services\MailService; +use App\Domains\Notification\Services\IdempotentEmailDeliveryService; use App\Domains\Notification\Services\NotificationMailService; use App\Domains\Purchase\Models\Purchase; use App\Domains\Ticket\Services\TicketPdfService; @@ -28,6 +29,7 @@ class NotificationMailServiceLoggingTest extends TestCase $this->service = new NotificationMailService( $this->mailService, Mockery::mock(TicketPdfService::class), + Mockery::mock(IdempotentEmailDeliveryService::class), ); }