File: /home/xedaptot/ai.naniguide.com/app/SendingServers/Identities/IdentitySyncService.php
<?php
namespace App\SendingServers\Identities;
use App\Model\Customer;
use App\Model\SendingIdentity;
use App\Model\SendingServer;
use App\SendingServers\Capabilities\SupportsIdentitySync;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use LogicException;
use RuntimeException;
/**
* Single orchestration site for identity sync. Drivers expose pure
* `fetchIdentities()`; this service does the diff against the
* `sending_identities` table:
*
* - vendor only → INSERT (Acelle defaults: shared=false, enabled=true)
* - vendor + Acelle both → UPDATE (vendor fields only; preserve shared/enabled/customer_id)
* - Acelle only (stale) → mark status=ARCHIVED (preserves audit, auto-revives on re-add)
*
* Only touches VENDOR_SYNC rows. LOCAL_DKIM (server-less) and MANUAL
* (admin-curated) identities are never affected by sync.
*/
final class IdentitySyncService
{
/**
* @param ?Customer $actor If a specific customer triggered the sync (e.g.
* self-service flow), insert new rows with this
* customer as the registered owner.
*/
public function __construct(private readonly ?Customer $actor = null)
{
}
public function sync(SendingServer $server): SyncReport
{
$driver = $server->driver();
if (!$driver instanceof SupportsIdentitySync) {
throw new LogicException(
"Server {$server->type} does not support identity sync (driver missing SupportsIdentitySync)"
);
}
// Collapse per-key duplicates from the vendor response BEFORE the
// diff loop — see collapseDuplicates() for the WHY and merge rules.
$vendorIdentities = $this->collapseDuplicates($driver->fetchIdentities());
$existing = $server->identities()
->ofMode(ManagementMode::VENDOR_SYNC)
->get()
->keyBy(fn (SendingIdentity $i) => $this->key($i->kind, $i->value));
$this->guardVendorResponse($vendorIdentities, $existing);
$vendorKeys = collect($vendorIdentities)
->keyBy(fn (Identity $d) => $d->key());
return DB::transaction(function () use ($server, $vendorIdentities, $existing, $vendorKeys) {
$report = new SyncReport();
foreach ($vendorIdentities as $dto) {
$key = $dto->key();
if ($existing->has($key)) {
$row = $existing[$key];
$row->status = $dto->status;
$row->dkim_status = $dto->dkimStatus;
$row->display_name = $dto->displayName ?? $row->display_name;
$row->error_type = $dto->errorType;
$row->vendor_meta = $dto->vendorMeta;
$row->last_synced_at = Carbon::now();
$row->save();
$report->updated++;
} else {
SendingIdentity::create([
'sending_server_id' => $server->id,
'customer_id' => $this->actor?->id,
'kind' => $dto->kind,
'value' => $dto->value,
'display_name' => $dto->displayName,
'status' => $dto->status,
'dkim_status' => $dto->dkimStatus,
'error_type' => $dto->errorType,
'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => false,
'enabled' => true,
'vendor_meta' => $dto->vendorMeta,
'last_synced_at' => Carbon::now(),
]);
$report->inserted++;
}
}
// Stale → ARCHIVED (preserve audit + provenance)
$staleKeys = $existing->keys()->diff($vendorKeys->keys());
foreach ($staleKeys as $key) {
$row = $existing[$key];
if ($row->last_synced_at !== null
&& $row->status !== IdentityStatus::ARCHIVED) {
$row->status = IdentityStatus::ARCHIVED;
$row->last_synced_at = Carbon::now();
$row->save();
$report->archived++;
}
// Skip if last_synced_at IS NULL (Acelle-optimistic edge) or
// already ARCHIVED (idempotent).
}
return $report;
});
}
/**
* Sanity guard: a vendor returning empty list while we have rows
* is more likely a transient API failure than a legitimate "all
* identities removed" event. Refuse to mark every row archived.
*/
private function guardVendorResponse(array $vendorIdentities, Collection $existing): void
{
if (empty($vendorIdentities) && $existing->isNotEmpty()) {
throw new RuntimeException(sprintf(
'Vendor returned empty identity list while Acelle has %d VENDOR_SYNC rows. ' .
'Refusing to mark all archived — likely transient vendor failure. ' .
'Re-run manually if intentional.',
$existing->count()
));
}
}
private function key(IdentityKind $kind, string $value): string
{
return "{$kind->value}:{$value}";
}
/**
* Some vendors return MULTIPLE auth records for the same (kind, value)
* in a single fetchIdentities() response. SendGrid is the canonical
* case: the same apex domain can be authenticated more than once on
* one account (e.g., older `automatic_security=true` record with an
* `em####` branded subdomain + newer `automatic_security=false`
* record with a hex-hash subdomain). Each yields its own
* `whitelabel/domains` row with the same `domain` field.
*
* Acelle models one identity per `(sending_server_id, kind, value)`
* (DB unique key `uniq_server_kind_value`). Without this collapse:
*
* - $existing (line 49) is built ONCE before the loop and never
* reflects in-loop inserts.
* - Iter 1 hits dup A → not in $existing → INSERT (commits inside tx)
* - Iter 2 hits dup B (same key) → still not in $existing → INSERT
* → 1062 collision on uniq_server_kind_value → whole DB::transaction
* rolls back → no row persists → sync never converges → Edit page
* catches QueryException → auto-disables the sending server.
*
* Past incident 2026-05-15 (zartech): SendGrid server #7 returned
* `cyberator.net × 2` + `zartech.net × 2`, breaking the Edit page banner
* and disabling the server. See `.support/clients/abu-sadeq-zartech.md`.
*
* Subdomain (`em2195`, `94aaaf03ed9f`, ...) is SendGrid plumbing for
* the CNAME prefix — it does NOT affect what addresses Acelle can send
* from. The apex domain IS the identity from Acelle's POV.
*
* Winner-of-group rule (best-of-set):
* 1. Highest IdentityStatus rank: VERIFIED > PENDING > FAILED > DISABLED > ARCHIVED
* 2. vendor_meta.default == true (vendor's own "primary" marker)
* 3. vendor_meta.valid == true (working CNAMEs / actually usable)
* 4. Highest vendor_meta.id (newest record breaks ties deterministically)
*
* dkimStatus is taken as best-of-set independently — apex DKIM is
* "set up" if ANY auth record's DKIM resolves, not necessarily the
* record we picked as winner.
*
* vendor_meta of the winner is kept verbatim, plus an `aux_records`
* array describing the dups we discarded — so admin debugging can
* answer "why does Acelle show 1 row when SendGrid lists 2?" by
* inspecting vendor_meta without re-calling the vendor API.
*
* @param Identity[] $vendorIdentities
* @return Identity[]
*/
private function collapseDuplicates(array $vendorIdentities): array
{
$statusRank = static fn (IdentityStatus $s): int => match ($s) {
IdentityStatus::VERIFIED => 5,
IdentityStatus::PENDING => 4,
IdentityStatus::FAILED => 3,
IdentityStatus::DISABLED => 2,
IdentityStatus::ARCHIVED => 1,
};
return collect($vendorIdentities)
->groupBy(fn (Identity $i) => $i->key())
->map(function ($group) use ($statusRank) {
if ($group->count() === 1) {
return $group->first();
}
// Multi-criteria sort via array tuple — PHP's <=> compares
// arrays element-wise, so sortByDesc on [rank, default, valid, id]
// gives lexicographic priority in that exact order.
$sorted = $group->sortByDesc(fn (Identity $i) => [
$statusRank($i->status),
(int) ($i->vendorMeta['default'] ?? 0),
(int) ($i->vendorMeta['valid'] ?? 0),
(int) ($i->vendorMeta['id'] ?? 0),
])->values();
$winner = $sorted->first();
$bestDkim = $group
->map(fn (Identity $i) => $i->dkimStatus)
->filter()
->sortByDesc(fn (IdentityStatus $s) => $statusRank($s))
->first();
$auxRecords = $sorted->slice(1)->map(fn (Identity $i) => [
'id' => $i->vendorMeta['id'] ?? null,
'subdomain' => $i->vendorMeta['subdomain'] ?? null,
'status' => $i->status->value,
'valid' => $i->vendorMeta['valid'] ?? null,
])->values()->all();
$mergedMeta = $winner->vendorMeta;
if (!empty($auxRecords)) {
$mergedMeta['aux_records'] = $auxRecords;
}
return new Identity(
kind: $winner->kind,
value: $winner->value,
status: $winner->status,
dkimStatus: $bestDkim ?? $winner->dkimStatus,
displayName: $winner->displayName
?? $group->map(fn (Identity $i) => $i->displayName)->filter()->first(),
errorType: $winner->errorType,
vendorMeta: $mergedMeta,
);
})
->values()
->all();
}
}