File: /home/xedaptot/be.naniguide.com/app/Model/SendingIdentity.php
<?php
namespace App\Model;
use App\Library\Domain;
use App\Library\Traits\HasUid;
use App\SendingServers\Capabilities\AllowsCrossSendingDomain;
use App\SendingServers\Capabilities\SupportsIdentitySync;
use App\SendingServers\Capabilities\SupportsRemoteDomainVerify;
use App\SendingServers\DomainVerification\CheckStatus;
use App\SendingServers\DomainVerification\DnsRecord;
use App\SendingServers\DomainVerification\RecordPurpose;
use App\SendingServers\DomainVerification\SpfVerifier;
use App\SendingServers\DomainVerification\VerificationResult;
use App\SendingServers\Identities\IdentityKind;
use App\SendingServers\Identities\IdentityStatus;
use App\SendingServers\Identities\ManagementMode;
use Carbon\Carbon;
use DateTimeImmutable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use InvalidArgumentException;
use LogicException;
/**
* Unified identity row replacing the legacy `sending_domains` table +
* `SendingServer.options['identities']` JSON dict + `IdentityStore` library.
*
* Three lifecycles via `management_mode`:
* LOCAL_DKIM — server-less, Acelle generates + signs DKIM, local DNS check
* VENDOR_SYNC — server-attached, vendor manages list + DKIM, auto-sync
* MANUAL — server-attached, vendor without sync API, admin curates manually
*
* Mode is determined automatically at create time (see
* IdentityCreationService::determineMode); never user-chosen. Constraints
* enforced in `booted()` saving hook.
*/
class SendingIdentity extends Model
{
use HasUid;
public const VALIDATION_REGEXP = '/^(?!\-)(?:(?:[a-zA-Z\d][a-zA-Z\d\-]{0,61})?[a-zA-Z\d]\.){1,126}(?!\d+)[a-zA-Z\d]{1,63}$/i';
/** Items per page — used by listing UI pagination helpers. */
public static int $itemsPerPage = 25;
protected $fillable = [
'sending_server_id',
'customer_id',
'kind',
'value',
'display_name',
'status',
'dkim_status',
'error_type',
'management_mode',
'shared',
'enabled',
'dkim_private',
'dkim_public',
'dkim_selector',
'signing_enabled',
'verification_records',
'vendor_meta',
'last_synced_at',
];
protected $casts = [
'kind' => IdentityKind::class,
'status' => IdentityStatus::class,
'dkim_status' => IdentityStatus::class,
'management_mode' => ManagementMode::class,
'shared' => 'bool',
'enabled' => 'bool',
'signing_enabled' => 'bool',
'verification_records' => 'array',
'vendor_meta' => 'array',
'last_synced_at' => 'datetime',
];
// ------------------------------------------------------------------
// Relations
// ------------------------------------------------------------------
public function sendingServer(): BelongsTo
{
return $this->belongsTo(SendingServer::class);
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
// ------------------------------------------------------------------
// Scopes
// ------------------------------------------------------------------
public function scopeVerified(Builder $q): Builder
{
return $q->where('status', IdentityStatus::VERIFIED->value);
}
public function scopeEnabled(Builder $q): Builder
{
return $q->where('enabled', true);
}
public function scopeShared(Builder $q): Builder
{
return $q->where('shared', true);
}
public function scopeOfKind(Builder $q, IdentityKind $kind): Builder
{
return $q->where('kind', $kind->value);
}
public function scopeOfMode(Builder $q, ManagementMode $mode): Builder
{
return $q->where('management_mode', $mode->value);
}
public function scopeBySendingServer(Builder $q, SendingServer $server): Builder
{
return $q->where('sending_server_id', $server->id);
}
public function scopeSearch(Builder $q, ?string $keyword): Builder
{
if (!empty($keyword)) {
$q->where('value', 'like', '%' . $keyword . '%');
}
return $q;
}
/**
* Visibility predicate as a query scope. Mirrors `isVisibleTo()`:
* - server-less LOCAL_DKIM → only owner
* - customer-owned server → only that server's owner
* - admin server → shared OR identity owned by customer
*/
public function scopeVisibleTo(Builder $q, ?Customer $customer): Builder
{
$customerId = $customer?->id;
return $q->where(function (Builder $qq) use ($customerId) {
// Server-less LOCAL_DKIM: only owner
$qq->where(function (Builder $sub) use ($customerId) {
$sub->whereNull('sending_server_id');
if ($customerId !== null) {
$sub->where('customer_id', $customerId);
} else {
$sub->whereRaw('1 = 0');
}
});
// Server-attached: depends on server ownership
$qq->orWhereHas('sendingServer', function (Builder $serverQ) use ($customerId) {
$serverQ->where(function (Builder $sub) use ($customerId) {
// Customer-owned server: only its owner sees identities
if ($customerId !== null) {
$sub->where('customer_id', $customerId);
} else {
$sub->whereRaw('1 = 0');
}
});
});
// Admin-owned server: shared OR identity-owned-by-customer
$qq->orWhere(function (Builder $sub) use ($customerId) {
$sub->whereNotNull('sending_server_id')
->whereHas('sendingServer', fn ($s) => $s->whereNull('customer_id'))
->where(function (Builder $vis) use ($customerId) {
$vis->where('shared', true);
if ($customerId !== null) {
$vis->orWhere('customer_id', $customerId);
}
});
});
});
}
/**
* Object-form mirror of scopeVisibleTo. Single source of truth for
* "can this customer see this identity in their droplist?".
*/
public function isVisibleTo(?Customer $customer): bool
{
// 1. Server-less LOCAL_DKIM: visible only to owner
if ($this->sending_server_id === null) {
return $customer && $customer->id === $this->customer_id;
}
$server = $this->sendingServer;
if (!$server) {
return false;
}
// 2. Customer-owned server: visible only to that customer
if ($server->customer_id !== null) {
return $customer && $customer->id === $server->customer_id;
}
// 3. Admin server: shared OR identity owned by this customer
return $this->shared
|| ($customer && $customer->id === $this->customer_id);
}
/**
* Whether this identity can be used as a From: address when sending
* through `$server`. Three rules combine:
*
* 1. Tied to this server (sending_server_id == $server->id) → always OK.
*
* 2. Server-less LOCAL_DKIM identity:
* - $server's driver MUST implement `AllowsLocalDomainVerify`,
* otherwise Acelle's locally-signed DKIM won't be honored
* (vendor-API drivers like SES sign DKIM themselves and reject
* identities they don't know).
*
* 3. Tied to a DIFFERENT server (cross-server use):
* - $server's driver MUST implement `AllowsCrossSendingDomain`.
*/
public function isAllowedBy(SendingServer $server): bool
{
// Same-server attachment is always allowed.
if ($this->sending_server_id === $server->id) {
return true;
}
$driver = $server->driver();
// Server-less LOCAL_DKIM identity — depends on Acelle-side DKIM support.
if ($this->sending_server_id === null) {
return $driver instanceof \App\SendingServers\Capabilities\AllowsLocalDomainVerify;
}
// Cross-server attachment.
return $driver instanceof AllowsCrossSendingDomain;
}
/**
* Human-readable reason if `isAllowedBy` would return false. Returns
* null when the identity IS allowed.
*/
public function blockedReasonFor(SendingServer $server): ?string
{
if ($this->isAllowedBy($server)) {
return null;
}
if ($this->sending_server_id === null) {
return sprintf(
"Local-DKIM domains aren't honored by %s — its vendor signs DKIM and only accepts vendor-verified identities.",
$server->driver()->getServiceName(),
);
}
return sprintf(
"This domain is tied to a different server. %s does not allow cross-server domain use.",
$server->driver()->getServiceName(),
);
}
// ------------------------------------------------------------------
// DnsRecord helpers
// ------------------------------------------------------------------
/**
* @return list<DnsRecord>
*/
public function records(): array
{
$raw = $this->verification_records ?? [];
return array_map(fn (array $r) => DnsRecord::fromArray($r), $raw);
}
/**
* @return list<DnsRecord>
*/
public function recordsByPurpose(RecordPurpose $purpose): array
{
return array_values(array_filter(
$this->records(),
fn (DnsRecord $r) => $r->purpose === $purpose,
));
}
public function isRecordPurposeNeeded(RecordPurpose $purpose): bool
{
return count($this->recordsByPurpose($purpose)) > 0;
}
/**
* Are all records for a purpose passing? Returns false if no records
* of that purpose exist (caller should check `isRecordPurposeNeeded`).
*/
public function isRecordPurposeVerified(RecordPurpose $purpose): bool
{
$records = $this->recordsByPurpose($purpose);
if (empty($records)) {
return false;
}
foreach ($records as $r) {
if ($r->status !== CheckStatus::PASSED) {
return false;
}
}
return true;
}
public function isOwnershipVerified(): bool
{
return $this->isRecordPurposeVerified(RecordPurpose::OWNERSHIP);
}
public function isDkimVerified(): bool
{
return $this->isRecordPurposeVerified(RecordPurpose::DKIM);
}
public function isSpfVerified(): bool
{
return $this->isRecordPurposeVerified(RecordPurpose::SPF);
}
public function isDmarcVerified(): bool
{
return $this->isRecordPurposeVerified(RecordPurpose::DMARC);
}
/**
* Persist a VerificationResult onto this row (vendor fields only;
* Acelle-state preserved). Used by registerDomain / pollDomain paths
* + LOCAL_DKIM verify after local DNS check.
*/
public function applyVerificationResult(VerificationResult $result): void
{
$this->verification_records = array_map(
fn (DnsRecord $r) => $r->toArray(),
$result->records,
);
$this->status = $result->verified ? IdentityStatus::VERIFIED : IdentityStatus::PENDING;
$this->error_type = $result->errorType;
if ($this->management_mode === ManagementMode::VENDOR_SYNC) {
$this->last_synced_at = Carbon::instance(
\DateTime::createFromImmutable($result->polledAt)
);
}
// Merge vendor-supplied identifiers (e.g. Emailit's uuid) so later
// pollDomain calls can resolve the vendor URL without re-listing.
// Merge (not replace) preserves any keys other paths populated.
if (!empty($result->vendorMeta)) {
$this->vendor_meta = array_merge(
is_array($this->vendor_meta) ? $this->vendor_meta : [],
$result->vendorMeta,
);
}
$this->save();
}
// ------------------------------------------------------------------
// DKIM helpers (LOCAL_DKIM only)
// ------------------------------------------------------------------
/**
* Materialize this row's DKIM keypair into the legacy Domain wrapper
* for code paths that still expect a Domain object (campaign DKIM
* signing in HasEmailTemplate). LOCAL_DKIM rows only.
*/
public function domainObject(): Domain
{
if ($this->management_mode !== ManagementMode::LOCAL_DKIM) {
throw new LogicException(
"domainObject() only available on LOCAL_DKIM rows; got {$this->management_mode->value}"
);
}
$selector = !empty($this->dkim_selector)
? $this->dkim_selector
: Setting::get('dkim_selector');
return new Domain(
$this->value,
$selector,
$this->dkim_public,
$this->dkim_private,
);
}
public function setDkimSelector(string $selector): bool
{
if (!preg_match('/^[a-z0-9]{1,24}$/', $selector)) {
return false;
}
$this->dkim_selector = $selector;
$this->save();
return true;
}
// ------------------------------------------------------------------
// Verify entry point — branches by mode
// ------------------------------------------------------------------
/**
* Re-verify this identity. Branch based on management_mode:
* LOCAL_DKIM → DNS-check own records, update statuses
* VENDOR_SYNC → driver pollDomain (kind=domain) or fetchIdentity (kind=email)
* MANUAL → no-op (admin sets status by hand)
*/
public function verify(): void
{
match ($this->management_mode) {
ManagementMode::LOCAL_DKIM => $this->verifyLocally(),
ManagementMode::VENDOR_SYNC => $this->refreshFromVendor(),
ManagementMode::MANUAL => null, // no automated verify
};
}
private function verifyLocally(): void
{
$records = $this->records();
if (empty($records)) {
return;
}
$updated = [];
$allPassed = true;
foreach ($records as $r) {
$passed = $this->dnsCheck($r);
// DNS verification is binary: either the record is there
// (PASSED) or the customer hasn't published it yet (PENDING).
// No terminal FAILED state — they're always one DNS edit away
// from making it correct. CheckStatus::FAILED is reserved for
// vendor-side terminal errors (e.g. SES VerificationStatus=Failed).
$updated[] = $r->withStatus($passed ? CheckStatus::PASSED : CheckStatus::PENDING);
if (!$passed) {
$allPassed = false;
}
}
$this->verification_records = array_map(fn (DnsRecord $r) => $r->toArray(), $updated);
$this->status = $allPassed ? IdentityStatus::VERIFIED : IdentityStatus::PENDING;
$this->save();
}
/**
* DNS verification for a single record. Branches by purpose:
*
* SPF — semantic RFC 7208 evaluation via louis/spfcheck. The
* customer's published SPF is valid as long as every
* `include:` from the expected SPF resolves to PASS when
* evaluated against a representative IP from that include's
* own SPF. Extra mechanisms (`ip4:...`, `a:...`), reordering,
* and qualifier differences (`~all` vs `-all`) are tolerated.
*
* CNAME — exact target match (DKIM CNAMEs are deterministic strings).
*
* other TXT (OWNERSHIP, DKIM TXT, DMARC) — whitespace-collapsed
* substring match; vendors emit tokens that are expected to
* appear verbatim in the actual record.
*/
private function dnsCheck(DnsRecord $record): bool
{
if ($record->value === null) {
// Vendor never surfaced the value (e.g., ElasticEmail DKIM); cannot DNS-check.
return false;
}
if ($record->purpose === RecordPurpose::SPF) {
return $this->checkSpfSemantic($record);
}
$type = strtoupper($record->type);
$dnsType = match ($type) {
'TXT' => DNS_TXT,
'CNAME' => DNS_CNAME,
default => DNS_ANY,
};
$records = @dns_get_record($record->name, $dnsType);
if (!$records) {
return false;
}
foreach ($records as $r) {
if ($type === 'TXT' && isset($r['txt'])) {
$needle = preg_replace('/\s+/', '', $record->value);
$hay = preg_replace('/\s+/', '', $r['txt']);
if (str_contains($hay, $needle)) {
return true;
}
}
if ($type === 'CNAME' && isset($r['target'])) {
if (rtrim($r['target'], '.') === rtrim($record->value, '.')) {
return true;
}
}
}
return false;
}
/**
* SPF verification — delegates to SpfVerifier (RFC 7208 evaluator).
* See SpfVerifier::check() for the algorithm + rationale.
*/
private function checkSpfSemantic(DnsRecord $record): bool
{
return SpfVerifier::check((string) $this->value, (string) $record->value) === CheckStatus::PASSED;
}
private function refreshFromVendor(): void
{
$server = $this->sendingServer;
if (!$server) {
throw new LogicException('VENDOR_SYNC identity missing sending_server');
}
$driver = $server->driver();
if ($this->kind === IdentityKind::DOMAIN
&& $driver instanceof SupportsRemoteDomainVerify) {
$result = $driver->pollDomain($this->value);
$this->applyVerificationResult($result);
return;
}
if ($driver instanceof SupportsIdentitySync) {
$dto = $driver->fetchIdentity($this->kind, $this->value);
if ($dto === null) {
$this->status = IdentityStatus::ARCHIVED;
} else {
$this->status = $dto->status;
$this->dkim_status = $dto->dkimStatus;
$this->display_name = $dto->displayName ?? $this->display_name;
$this->error_type = $dto->errorType;
$this->vendor_meta = $dto->vendorMeta;
}
$this->last_synced_at = Carbon::now();
$this->save();
return;
}
throw new LogicException(
"Server {$server->type} cannot refresh identity (no remote-verify or sync capability)"
);
}
// ------------------------------------------------------------------
// Boot — mode invariants + uniqueness
// ------------------------------------------------------------------
protected static function booted(): void
{
static::saving(function (self $i): void {
$i->validateModeInvariants();
$i->validateUniqueScope();
});
}
private function validateModeInvariants(): void
{
$mode = $this->management_mode instanceof ManagementMode
? $this->management_mode
: ManagementMode::from($this->management_mode);
match ($mode) {
ManagementMode::LOCAL_DKIM => $this->assertLocalDkimInvariants(),
ManagementMode::VENDOR_SYNC => $this->assertVendorSyncInvariants(),
ManagementMode::MANUAL => $this->assertManualInvariants(),
};
}
private function assertLocalDkimInvariants(): void
{
if ($this->sending_server_id !== null) {
throw new InvalidArgumentException('LOCAL_DKIM requires sending_server_id IS NULL');
}
if ($this->customer_id === null) {
throw new InvalidArgumentException('LOCAL_DKIM requires customer_id (owner)');
}
$kind = $this->kind instanceof IdentityKind ? $this->kind : IdentityKind::from($this->kind);
if ($kind !== IdentityKind::DOMAIN) {
throw new InvalidArgumentException('LOCAL_DKIM only supports kind=domain');
}
if (empty($this->dkim_private) || empty($this->dkim_public)) {
throw new InvalidArgumentException('LOCAL_DKIM requires populated DKIM keypair');
}
}
private function assertVendorSyncInvariants(): void
{
if ($this->sending_server_id === null) {
throw new InvalidArgumentException('VENDOR_SYNC requires sending_server_id NOT NULL');
}
if (!empty($this->dkim_private) || !empty($this->dkim_public)) {
throw new InvalidArgumentException('VENDOR_SYNC must not carry DKIM keypair (vendor signs)');
}
}
private function assertManualInvariants(): void
{
if ($this->sending_server_id === null) {
throw new InvalidArgumentException('MANUAL requires sending_server_id NOT NULL');
}
if (!empty($this->dkim_private) || !empty($this->dkim_public)) {
throw new InvalidArgumentException('MANUAL must not carry DKIM keypair (vendor or relay handles signing)');
}
if ($this->last_synced_at !== null) {
throw new InvalidArgumentException('MANUAL must have last_synced_at NULL (no vendor sync)');
}
}
/**
* Server-less LOCAL_DKIM uniqueness — MySQL allows duplicate NULLs in
* unique-key columns, so enforce here. Server-attached uniqueness is
* enforced by the DB-level uniq_server_kind_value index.
*/
private function validateUniqueScope(): void
{
if ($this->sending_server_id !== null) {
return; // DB-level UNIQUE handles this
}
$exists = static::query()
->whereNull('sending_server_id')
->where('customer_id', $this->customer_id)
->where('kind', $this->kind instanceof IdentityKind ? $this->kind->value : $this->kind)
->where('value', $this->value)
->where('id', '!=', $this->id ?? 0)
->exists();
if ($exists) {
$kindStr = $this->kind instanceof IdentityKind ? $this->kind->value : $this->kind;
throw new InvalidArgumentException(
"Duplicate server-less identity {$kindStr}:{$this->value} for customer {$this->customer_id}"
);
}
}
}