113 lines
3.6 KiB
PHP
113 lines
3.6 KiB
PHP
<?php
|
|
|
|
namespace App\Domains\Notification\Services;
|
|
|
|
use App\Domains\Notification\Models\EmailDelivery;
|
|
use Closure;
|
|
use Illuminate\Database\Query\Expression;
|
|
use Illuminate\Support\Str;
|
|
use Throwable;
|
|
|
|
class IdempotentEmailDeliveryService
|
|
{
|
|
/**
|
|
* @param array<string, mixed> $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'),
|
|
);
|
|
}
|
|
}
|