HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
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}"
            );
        }
    }
}