File: /home/xedaptot/ai.naniguide.com/app/SendingServers/Identities/IdentityCreationService.php
<?php
namespace App\SendingServers\Identities;
use App\Model\Customer;
use App\Model\SendingIdentity;
use App\Model\SendingServer;
use App\Model\Setting;
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 Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use LogicException;
/**
* Single creation gateway for sending identities.
*
* Determines `management_mode` automatically from the (server, kind)
* pair — never user-chosen — and runs the appropriate side-effect:
* LOCAL_DKIM : generate DKIM keypair + local DNS records (server-less only)
* VENDOR_SYNC : push to vendor via SupportsRemoteDomainVerify::registerDomain (kind=domain)
* or simply persist optimistic row (kind=email; vendor sync confirms later)
* MANUAL : just insert; vendor (relay/Postal) handles signing if any
*/
final class IdentityCreationService
{
public function create(
?SendingServer $server,
IdentityKind $kind,
string $value,
Customer $customer,
?string $displayName = null,
): SendingIdentity {
$mode = $this->determineMode($server);
return match ($mode) {
ManagementMode::LOCAL_DKIM => $this->createLocalDkim($kind, $value, $customer, $displayName),
ManagementMode::VENDOR_SYNC => $this->createVendorSync($server, $kind, $value, $customer, $displayName),
ManagementMode::MANUAL => $this->createManual($server, $kind, $value, $customer, $displayName),
};
}
/**
* Admin asserts that an identity exists on the server. Mode auto-detected
* from the driver's sync capability — VENDOR_SYNC for SES/SendGrid/etc.
* (admin-asserted; next IdentitySyncService run reconciles against vendor),
* MANUAL for SMTP/Sendmail/Postal/custom relays (no auto-reconciliation).
*/
public function createByAdmin(
SendingServer $server,
IdentityKind $kind,
string $value,
IdentityStatus $initialStatus = IdentityStatus::PENDING,
?string $displayName = null,
bool $shared = false,
): SendingIdentity {
$mode = $server->driver() instanceof SupportsIdentitySync
? ManagementMode::VENDOR_SYNC
: ManagementMode::MANUAL;
return SendingIdentity::create([
'sending_server_id' => $server->id,
'customer_id' => null,
'kind' => $kind,
'value' => $value,
'display_name' => $displayName,
'status' => $initialStatus,
'management_mode' => $mode,
'shared' => $shared,
'enabled' => true,
'last_synced_at' => $mode === ManagementMode::VENDOR_SYNC ? now() : null,
]);
}
public function determineMode(?SendingServer $server): ManagementMode
{
if ($server === null) {
return ManagementMode::LOCAL_DKIM;
}
if ($server->driver() instanceof SupportsIdentitySync) {
return ManagementMode::VENDOR_SYNC;
}
return ManagementMode::MANUAL;
}
// ------------------------------------------------------------------
private function createLocalDkim(
IdentityKind $kind,
string $value,
Customer $customer,
?string $displayName,
): SendingIdentity {
if ($kind !== IdentityKind::DOMAIN) {
throw new InvalidArgumentException('LOCAL_DKIM only supports kind=domain');
}
$keys = $this->generateDkimKeys();
$selector = Setting::get('dkim_selector') ?: 'acelle';
$records = $this->buildLocalDnsRecords($value, $selector, $keys['public']);
return DB::transaction(function () use ($value, $displayName, $customer, $keys, $selector, $records) {
return SendingIdentity::create([
'sending_server_id' => null,
'customer_id' => $customer->id,
'kind' => IdentityKind::DOMAIN,
'value' => $value,
'display_name' => $displayName,
'status' => IdentityStatus::PENDING,
'management_mode' => ManagementMode::LOCAL_DKIM,
'shared' => false,
'enabled' => true,
'dkim_private' => $keys['private'],
'dkim_public' => $keys['public'],
'dkim_selector' => $selector,
'signing_enabled' => true,
'verification_records' => array_map(fn (DnsRecord $r) => $r->toArray(), $records),
]);
});
}
private function createVendorSync(
SendingServer $server,
IdentityKind $kind,
string $value,
Customer $customer,
?string $displayName,
): SendingIdentity {
$row = SendingIdentity::create([
'sending_server_id' => $server->id,
'customer_id' => $customer->id,
'kind' => $kind,
'value' => $value,
'display_name' => $displayName,
'status' => IdentityStatus::PENDING,
'management_mode' => ManagementMode::VENDOR_SYNC,
'shared' => false,
'enabled' => true,
]);
// Push domain to vendor if driver supports remote-verify; vendor
// returns DNS records customer must add. Email-kind identities
// typically use vendor's verify-by-click flow (SES SendCustomVerificationEmail
// via SendsCustomVerificationEmail capability — separate concern).
if ($kind === IdentityKind::DOMAIN
&& $server->driver() instanceof SupportsRemoteDomainVerify) {
$result = $server->driver()->registerDomain($value);
$row->applyVerificationResult($result);
}
return $row;
}
private function createManual(
SendingServer $server,
IdentityKind $kind,
string $value,
Customer $customer,
?string $displayName,
): SendingIdentity {
return SendingIdentity::create([
'sending_server_id' => $server->id,
'customer_id' => $customer->id,
'kind' => $kind,
'value' => $value,
'display_name' => $displayName,
'status' => IdentityStatus::PENDING,
'management_mode' => ManagementMode::MANUAL,
'shared' => false,
'enabled' => true,
]);
}
// ------------------------------------------------------------------
// Local DKIM helpers (LOCAL_DKIM only)
// ------------------------------------------------------------------
/**
* @return array{private: string, public: string}
*/
private function generateDkimKeys(): array
{
$config = [
'digest_alg' => 'sha256',
'private_key_bits' => 1024,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
];
$res = openssl_pkey_new($config);
if ($res === false) {
throw new LogicException('openssl_pkey_new failed; check OpenSSL extension config');
}
openssl_pkey_export($res, $privKey);
$details = openssl_pkey_get_details($res);
return [
'private' => $privKey,
'public' => $details['key'],
];
}
/**
* @return list<DnsRecord>
*/
private function buildLocalDnsRecords(string $domain, string $selector, string $publicKey): array
{
$records = [];
// Ownership TXT — verification token (preserves legacy semantics)
$records[] = new DnsRecord(
purpose: RecordPurpose::OWNERSHIP,
type: 'TXT',
name: $this->localOwnershipHost($domain),
value: $this->generateIdentityToken($domain),
status: CheckStatus::PENDING,
);
// DKIM TXT — public key record
$records[] = new DnsRecord(
purpose: RecordPurpose::DKIM,
type: 'TXT',
name: "{$selector}._domainkey.{$domain}",
value: $this->buildDkimDnsValue($publicKey),
status: CheckStatus::PENDING,
selector: $selector,
);
// SPF (optional) — only if Setting has spf_record configured
$spf = Setting::get('spf_record');
if (!empty($spf)) {
$records[] = new DnsRecord(
purpose: RecordPurpose::SPF,
type: 'TXT',
name: $domain,
value: $spf,
status: CheckStatus::PENDING,
);
}
return $records;
}
private function localOwnershipHost(string $domain): string
{
$verifyHost = Setting::get('verification_hostname') ?: '_acelle';
return "{$verifyHost}.{$domain}";
}
private function generateIdentityToken(string $domain): string
{
return base64_encode(md5(trim('SALT!' . $domain)));
}
private function buildDkimDnsValue(string $publicKey): string
{
$clean = str_replace(['-----BEGIN PUBLIC KEY-----', '-----END PUBLIC KEY-----'], '', $publicKey);
$clean = trim(preg_replace('/\s+/', '', $clean));
return sprintf('v=DKIM1; k=rsa; p=%s;', $clean);
}
}