File: /home/xedaptot/hi.naniguide.com/app/Services/DeliveryServerService.php
<?php
namespace App\Services;
use App\Models\Customer;
use App\Models\DeliveryServer;
use App\Models\DeliveryServerLog;
use App\Models\Setting;
use App\Mail\DeliveryServerVerificationMailable;
use App\Services\ZeptoMailApiService;
use App\Services\EmailitApiService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Collection;
class DeliveryServerService
{
protected function selectableDeliveryServersQueryForCustomer(Customer $customer, bool $mustAddDelivery = false, bool $canUseSystem = false): Builder
{
$workspaceId = session('active_workspace_id');
$allocatedSystemServerIds = collect();
if (Schema::hasTable('customer_delivery_server')) {
$allocatedSystemServerIds = $allocatedSystemServerIds->merge(
$customer->allocatedDeliveryServers()
->whereNull('delivery_servers.customer_id')
->pluck('delivery_servers.id')
->map(fn ($id) => (int) $id)
->filter(fn ($id) => $id > 0)
);
}
$groupIds = collect();
if (Schema::hasTable('customer_customer_group') || method_exists($customer, 'customerGroups')) {
$groupIds = $customer->customerGroups()->pluck('customer_groups.id')
->map(fn ($id) => (int) $id)
->filter(fn ($id) => $id > 0)
->unique()
->values();
}
if ($groupIds->isNotEmpty() && Schema::hasTable('customer_group_delivery_server')) {
$groupAllocatedIds = DB::table('customer_group_delivery_server')
->join('delivery_servers', 'delivery_servers.id', '=', 'customer_group_delivery_server.delivery_server_id')
->whereIn('customer_group_id', $groupIds->all())
->whereNull('delivery_servers.customer_id')
->whereNull('delivery_servers.deleted_at')
->pluck('delivery_server_id')
->map(fn ($id) => (int) $id)
->filter(fn ($id) => $id > 0)
->unique()
->values();
if ($groupAllocatedIds->isNotEmpty()) {
$allocatedSystemServerIds = $allocatedSystemServerIds->merge($groupAllocatedIds);
}
}
$allocatedSystemServerIds = $allocatedSystemServerIds
->unique()
->values();
if ($workspaceId) {
return DeliveryServer::query()
->where('status', 'active')
->when($allocatedSystemServerIds->isNotEmpty(), function ($q) use ($workspaceId, $allocatedSystemServerIds, $canUseSystem) {
$q->where(function ($sub) use ($workspaceId, $allocatedSystemServerIds, $canUseSystem) {
$sub->where('workspace_id', $workspaceId);
if ($canUseSystem) {
$sub->orWhereIn('id', $allocatedSystemServerIds->all())
->orWhereNull('workspace_id');
}
});
}, function ($q) use ($workspaceId, $canUseSystem) {
$q->where(function ($sub) use ($workspaceId, $canUseSystem) {
$sub->where('workspace_id', $workspaceId);
if ($canUseSystem) {
$sub->orWhereNull('workspace_id');
}
});
});
}
if ($allocatedSystemServerIds->isNotEmpty()) {
return DeliveryServer::query()
->where('status', 'active')
->where(function ($q) use ($customer, $allocatedSystemServerIds) {
$q->where('customer_id', $customer->id)
->orWhereIn('id', $allocatedSystemServerIds->all());
});
}
return DeliveryServer::query()
->where('status', 'active')
->where('use_for', true)
->when($mustAddDelivery, function ($q) use ($customer) {
$q->where('customer_id', $customer->id);
}, function ($q) use ($customer, $canUseSystem) {
$q->where(function ($sub) use ($customer, $canUseSystem) {
$sub->where('customer_id', $customer->id);
if ($canUseSystem) {
$sub->orWhereNull('customer_id');
}
});
});
}
public function querySelectableDeliveryServersForCustomer(Customer $customer, bool $mustAddDelivery = false, bool $canUseSystem = false): Builder
{
return $this->selectableDeliveryServersQueryForCustomer($customer, $mustAddDelivery, $canUseSystem);
}
public function getSelectableDeliveryServersForCustomer(Customer $customer, bool $mustAddDelivery = false, bool $canUseSystem = false): Collection
{
return $this->selectableDeliveryServersQueryForCustomer($customer, $mustAddDelivery, $canUseSystem)
->orderBy('name')
->get();
}
public function getSelectableDeliveryServerIdsForCustomer(Customer $customer, bool $mustAddDelivery = false, bool $canUseSystem = false): array
{
return $this->selectableDeliveryServersQueryForCustomer($customer, $mustAddDelivery, $canUseSystem)
->pluck('id')
->map(fn ($id) => (int) $id)
->filter(fn ($id) => $id > 0)
->values()
->all();
}
public function queryAdminEmailSettingDeliveryServers(): Builder
{
return DeliveryServer::query()
->where('status', 'active')
->whereNull('customer_id')
->orderByDesc('is_primary')
->orderBy('name');
}
public function resolveAdminEmailSettingDeliveryServer(mixed $selected): ?DeliveryServer
{
if ($selected instanceof DeliveryServer) {
return $selected->exists ? $selected : null;
}
if ($selected === null) {
return null;
}
if (is_string($selected)) {
$selected = trim($selected);
}
if ($selected === '' || $selected === 'auto' || $selected === 'inherit' || $selected === 'system') {
return null;
}
if (is_int($selected)) {
$serverId = $selected;
} elseif (is_string($selected) && ctype_digit($selected)) {
$serverId = (int) $selected;
} else {
return null;
}
if ($serverId <= 0) {
return null;
}
return $this->queryAdminEmailSettingDeliveryServers()
->whereKey($serverId)
->first();
}
public function resolveDeliveryServerForCustomer(Customer $customer, ?int $requestedServerId, bool $mustAddDelivery = false, bool $canUseSystem = false): ?DeliveryServer
{
if ($requestedServerId) {
$requested = $this->selectableDeliveryServersQueryForCustomer($customer, $mustAddDelivery, $canUseSystem)
->with('bounceServer')
->whereKey($requestedServerId)
->first();
if ($requested) {
return $requested;
}
}
return $this->selectableDeliveryServersQueryForCustomer($customer, $mustAddDelivery, $canUseSystem)
->with('bounceServer')
->orderBy('id')
->first();
}
protected function ensureProviderWebhooksStored(DeliveryServer $deliveryServer): void
{
try {
$type = $deliveryServer->type;
if (!in_array($type, ['mailgun', 'sendgrid', 'amazon-ses', 'resend'], true)) {
return;
}
$base = rtrim((string) config('app.url'), '/');
$urls = match ($type) {
'mailgun' => [
'bounce' => $base . '/webhooks/mailgun/bounce',
'open' => $base . '/webhooks/mailgun/open',
'click' => $base . '/webhooks/mailgun/click',
],
'sendgrid' => [
'bounce' => $base . '/webhooks/sendgrid/bounce',
'open' => $base . '/webhooks/sendgrid/open',
'click' => $base . '/webhooks/sendgrid/click',
],
'amazon-ses' => [
'sns' => $base . '/ses/sns',
],
'resend' => [
'webhook' => $base . '/webhooks/resend',
'bounce' => $base . '/webhooks/resend/bounce',
'open' => $base . '/webhooks/resend/open',
'click' => $base . '/webhooks/resend/click',
'delivered' => $base . '/webhooks/resend/delivered',
],
};
$settings = $deliveryServer->settings ?? [];
if ($type === 'amazon-ses') {
$existing = is_array($settings['webhooks'] ?? null) ? $settings['webhooks'] : [];
unset($existing['bounce'], $existing['open'], $existing['click']);
$settings['webhooks'] = array_merge($existing, $urls);
} else {
$settings['webhooks'] = array_merge($settings['webhooks'] ?? [], $urls);
}
$deliveryServer->update(['settings' => $settings]);
} catch (\Throwable $e) {
}
}
protected function sendSparkPostApiTestEmail(?DeliveryServer $server, array $data, string $fromEmail, string $fromName = ''): void
{
$rawKeyCandidates = $server
? [
'settings.secret' => $server->settings['secret'] ?? null,
'settings.api_key' => $server->settings['api_key'] ?? null,
'server.password' => $server->password,
'services.sparkpost.secret' => config('services.sparkpost.secret'),
]
: [
'request.api_key' => $data['api_key'] ?? null,
'request.api_secret' => $data['api_secret'] ?? null,
'services.sparkpost.secret' => config('services.sparkpost.secret'),
];
Log::info('SparkPost test email key candidates', [
'server_id' => $server?->id,
'raw_key_candidates' => $rawKeyCandidates,
]);
$keyCandidates = [];
foreach ($rawKeyCandidates as $source => $rawKey) {
$normalized = $this->normalizeSparkPostApiKey($rawKey);
if ($normalized !== '' && !in_array($normalized, array_column($keyCandidates, 'key'), true)) {
$keyCandidates[] = [
'source' => $source,
'key' => $normalized,
];
}
}
$endpoint = $server
? (($server->settings['endpoint'] ?? null)
?? $server->hostname
?? config('services.sparkpost.endpoint'))
: (($data['api_hostname'] ?? null)
?? ($data['hostname'] ?? null)
?? config('services.sparkpost.endpoint'));
if (empty($keyCandidates)) {
Log::error('SparkPost test email: No API key found', [
'raw_key_candidates' => $rawKeyCandidates,
'server_id' => $server?->id,
]);
throw new \Exception('SparkPost configuration incomplete: API key is required.');
}
$baseUrl = $this->normalizeSparkPostApiBaseUrl($endpoint);
$endpointCandidates = [$baseUrl];
if ($baseUrl === 'https://api.sparkpost.com') {
$endpointCandidates[] = 'https://api.eu.sparkpost.com';
} elseif ($baseUrl === 'https://api.eu.sparkpost.com') {
$endpointCandidates[] = 'https://api.sparkpost.com';
}
$rawBody = (string) ($data['message'] ?? '');
$looksLikeHtml = preg_match('/<\s*\w+[^>]*>/', $rawBody) === 1;
$htmlBody = $looksLikeHtml
? $rawBody
: '<div style="white-space: pre-wrap;">' . nl2br(htmlspecialchars($rawBody, ENT_QUOTES, 'UTF-8')) . '</div>';
$textBody = trim(preg_replace('/\s+/', ' ', strip_tags($rawBody)));
$payload = [
'content' => [
'from' => [
'email' => $fromEmail,
'name' => $fromName !== '' ? $fromName : null,
],
'subject' => (string) ($data['subject'] ?? 'This is a Test Email'),
'html' => $htmlBody,
'text' => $textBody !== '' ? $textBody : null,
],
'recipients' => [
[
'address' => [
'email' => (string) $data['to_email'],
],
],
],
];
$attemptErrors = [];
foreach ($endpointCandidates as $endpointCandidate) {
foreach ($keyCandidates as $candidate) {
$response = $this->sendSparkPostTransmissionWithHttp($endpointCandidate, $candidate['key'], $payload);
if ($response->successful()) {
return;
}
$errorBody = $response->body();
if (mb_strlen($errorBody) > 500) {
$errorBody = mb_substr($errorBody, 0, 500) . '...';
}
$attemptErrors[] = [
'endpoint' => $endpointCandidate,
'source' => $candidate['source'],
'status' => $response->status(),
'body' => $errorBody,
];
if ($response->status() !== 401) {
throw new \Exception('SparkPost API request failed (HTTP ' . $response->status() . ', endpoint: ' . $endpointCandidate . ', key source: ' . $candidate['source'] . '): ' . $errorBody);
}
}
}
$sourceEndpointPairs = implode(', ', array_map(static fn ($e) => $e['source'] . '@' . $e['endpoint'], $attemptErrors));
$last = end($attemptErrors);
$lastBody = is_array($last) ? ($last['body'] ?? 'Unauthorized.') : 'Unauthorized.';
throw new \Exception('SparkPost API request failed (HTTP 401): ' . $lastBody . ' Endpoints tried: ' . implode(', ', $endpointCandidates) . '. Key sources tried: ' . $sourceEndpointPairs . '.');
}
protected function sendSparkPostTransmissionWithHttp(string $endpointBaseUrl, string $apiKey, array $payload): \Illuminate\Http\Client\Response
{
$transmissionsUrl = rtrim($endpointBaseUrl, '/') . '/api/v1/transmissions';
return Http::timeout(20)
->withHeaders([
'Authorization' => $apiKey,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
])
->post($transmissionsUrl, $payload);
}
protected function normalizeSparkPostApiBaseUrl(mixed $endpoint): string
{
$value = is_string($endpoint) ? trim($endpoint) : '';
if (str_contains($value, 'smtp.sparkpostmail.com')) {
$value = str_replace('smtp.sparkpostmail.com', 'api.sparkpost.com', $value);
}
if (str_contains($value, 'smtp.eu.sparkpostmail.com')) {
$value = str_replace('smtp.eu.sparkpostmail.com', 'api.eu.sparkpost.com', $value);
}
if ($value === '') {
return 'https://api.sparkpost.com';
}
if (!str_starts_with($value, 'http://') && !str_starts_with($value, 'https://')) {
$value = 'https://' . $value;
}
$value = rtrim($value, '/');
if (str_ends_with($value, '/api/v1/transmissions')) {
$value = substr($value, 0, -strlen('/api/v1/transmissions'));
} elseif (str_ends_with($value, '/api/v1')) {
$value = substr($value, 0, -strlen('/api/v1'));
}
return rtrim($value, '/');
}
protected function normalizeSparkPostApiKey(mixed $secret): string
{
$value = is_string($secret) ? trim($secret) : '';
if (str_starts_with(strtolower($value), 'bearer ')) {
$value = trim(substr($value, 7));
}
return $value;
}
/**
* Get paginated list of delivery servers.
*/
public function getPaginated(array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$query = DeliveryServer::query();
$workspaceId = session('active_workspace_id');
if ($workspaceId) {
$query->where(function ($sub) use ($workspaceId) {
$sub->where('workspace_id', $workspaceId)
->orWhereNull('workspace_id');
});
}
if (array_key_exists('customer_id', $filters)) {
if ($filters['customer_id'] === null) {
$query->whereNull('customer_id');
} else {
$query->where('customer_id', $filters['customer_id']);
}
}
// Apply filters
if (isset($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('hostname', 'like', "%{$search}%")
->orWhere('from_email', 'like', "%{$search}%");
});
}
if (isset($filters['type'])) {
$query->where('type', $filters['type']);
}
if (isset($filters['status'])) {
$query->where('status', $filters['status']);
}
return $query->latest()->paginate($perPage);
}
/**
* Create a new delivery server.
*/
public function create(array $data): DeliveryServer
{
$settings = $data['settings'] ?? [];
$type = $data['type'] ?? 'smtp';
$isSmtpLike = in_array($type, ['smtp', 'zeptomail'], true);
$port = array_key_exists('port', $data) ? $data['port'] : null;
if ($port === null && $isSmtpLike) {
$port = 587;
}
$encryption = $this->normalizeEncryptionValue($data['encryption'] ?? null, $type);
$attributes = [
'customer_id' => $data['customer_id'] ?? null,
'name' => $data['name'],
'type' => $type,
'status' => $data['status'] ?? 'pending',
'hostname' => $data['hostname'] ?? null,
'username' => $data['username'] ?? null,
'password' => $data['password'] ?? null,
'from_email' => $data['from_email'] ?? null,
'from_name' => $data['from_name'] ?? null,
'reply_to_email' => $data['reply_to_email'] ?? null,
'timeout' => $data['timeout'] ?? 30,
'max_connection_messages' => $data['max_connection_messages'] ?? 100,
'second_quota' => $data['second_quota'] ?? 0,
'minute_quota' => $data['minute_quota'] ?? 0,
'hourly_quota' => $data['hourly_quota'] ?? 0,
'daily_quota' => $data['daily_quota'] ?? 0,
'monthly_quota' => $data['monthly_quota'] ?? 0,
'pause_after_send' => $data['pause_after_send'] ?? 0,
'settings' => $settings,
'locked' => $data['locked'] ?? false,
'use_for' => $data['use_for'] ?? true,
'use_for_email_to_list' => $data['use_for_email_to_list'] ?? false,
'use_for_transactional' => $data['use_for_transactional'] ?? false,
'bounce_server_id' => $data['bounce_server_id'] ?? null,
'tracking_domain_id' => $data['tracking_domain_id'] ?? null,
'notes' => $data['notes'] ?? null,
];
if ($port !== null) {
$attributes['port'] = $port;
}
if ($encryption !== null) {
$attributes['encryption'] = $encryption;
}
// Create the delivery server
$deliveryServer = DeliveryServer::create($attributes);
// Setup webhook for API-based servers
if ($deliveryServer->type === 'mailgun') {
$this->setupMailgunWebhook($deliveryServer, $data);
}
if ($deliveryServer->type === 'resend') {
$this->setupResendWebhook($deliveryServer);
}
$this->ensureProviderWebhooksStored($deliveryServer);
// Only SMTP-like servers require verification
if (
in_array($deliveryServer->type, ['smtp', 'gmail', 'outlook'], true)
&& !empty($deliveryServer->username)
&& filter_var($deliveryServer->username, FILTER_VALIDATE_EMAIL)
) {
try {
$this->sendVerificationEmail($deliveryServer);
} catch (\Throwable $e) {
Log::warning('Automatic verification email send failed after delivery server creation', [
'server_id' => $deliveryServer->id,
'email' => $deliveryServer->username,
'hostname' => $deliveryServer->hostname,
'error' => $e->getMessage(),
'exception' => get_class($e),
]);
}
}
return $deliveryServer;
}
/**
* Update an existing delivery server.
*/
public function update(DeliveryServer $deliveryServer, array $data): DeliveryServer
{
if (array_key_exists('settings', $data)) {
$existingSettings = is_array($deliveryServer->settings) ? $deliveryServer->settings : [];
$incomingSettings = is_array($data['settings']) ? $data['settings'] : [];
foreach (['secret', 'api_key', 'token', 'key', 'send_mail_token'] as $secretKey) {
if (!array_key_exists($secretKey, $incomingSettings)) {
continue;
}
$value = $incomingSettings[$secretKey];
$isMasked = is_string($value) && trim($value) === '********';
$isBlank = $value === null || (is_string($value) && trim($value) === '');
if (($isMasked || $isBlank) && array_key_exists($secretKey, $existingSettings)) {
$incomingSettings[$secretKey] = $existingSettings[$secretKey];
}
}
$data['settings'] = array_replace($existingSettings, $incomingSettings);
}
$currentType = (string) ($data['type'] ?? $deliveryServer->type ?? 'smtp');
if (array_key_exists('port', $data) && $data['port'] === null) {
unset($data['port']);
}
if (array_key_exists('encryption', $data)) {
$data['encryption'] = $this->normalizeEncryptionValue($data['encryption'], $currentType);
} elseif (!is_string($deliveryServer->encryption) || trim($deliveryServer->encryption) === '' || !in_array(strtolower(trim($deliveryServer->encryption)), ['ssl', 'tls', 'none'], true)) {
$data['encryption'] = $this->normalizeEncryptionValue(null, $currentType);
}
$deliveryServer->update($data);
$deliveryServer->refresh();
// Setup/update webhook for API-based servers
if ($deliveryServer->type === 'mailgun') {
$this->setupMailgunWebhook($deliveryServer, $data);
}
if ($deliveryServer->type === 'resend') {
$this->setupResendWebhook($deliveryServer);
}
$this->ensureProviderWebhooksStored($deliveryServer);
return $deliveryServer->fresh();
}
public function setPrimary(DeliveryServer $deliveryServer): DeliveryServer
{
return DB::transaction(function () use ($deliveryServer) {
DeliveryServer::query()
->when($deliveryServer->customer_id === null, function ($q) {
$q->whereNull('customer_id');
}, function ($q) use ($deliveryServer) {
$q->where('customer_id', $deliveryServer->customer_id);
})
->where('id', '!=', $deliveryServer->id)
->update(['is_primary' => false]);
$deliveryServer->update(['is_primary' => true]);
return $deliveryServer->fresh();
});
}
/**
* Delete a delivery server.
*/
public function delete(DeliveryServer $deliveryServer): bool
{
// Delete webhooks for API-based servers
if ($deliveryServer->type === 'mailgun') {
$this->deleteMailgunWebhook($deliveryServer);
}
if ($deliveryServer->type === 'resend') {
$this->deleteResendWebhook($deliveryServer);
}
$id = $deliveryServer->id;
// Null out delivery_server_id on campaigns that reference this server
DB::table('campaigns')->where('delivery_server_id', $id)->update(['delivery_server_id' => null]);
// Remove pivot allocations so the server no longer appears in customer dropdowns
if (Schema::hasTable('customer_delivery_server')) {
DB::table('customer_delivery_server')->where('delivery_server_id', $id)->delete();
}
if (Schema::hasTable('customer_group_delivery_server')) {
DB::table('customer_group_delivery_server')->where('delivery_server_id', $id)->delete();
}
return $deliveryServer->delete();
}
/**
* Setup Mailgun webhook for a delivery server.
*/
protected function setupMailgunWebhook(DeliveryServer $deliveryServer, array $data): void
{
try {
$webhookService = app(MailgunWebhookService::class);
$settings = $deliveryServer->settings ?? [];
// Get domain and API key from settings or data
$domain = $settings['domain'] ?? $data['settings']['domain'] ?? null;
$apiKey = $settings['secret'] ?? $data['settings']['secret'] ?? config('services.mailgun.secret');
// If using SMTP, try to extract domain from hostname or from_email
if (!$domain && !empty($deliveryServer->hostname)) {
// Mailgun SMTP hostname is usually smtp.mailgun.org or smtp.eu.mailgun.org
// Domain should be in settings or from_email
$domain = $settings['domain'] ?? null;
}
if (!$domain && !empty($deliveryServer->from_email)) {
// Extract domain from from_email as fallback
$domain = substr(strrchr($deliveryServer->from_email, "@"), 1);
}
if (!$domain || !$apiKey) {
Log::warning("Cannot setup Mailgun webhook: missing domain or API key", [
'server_id' => $deliveryServer->id,
'has_domain' => !empty($domain),
'has_api_key' => !empty($apiKey),
]);
return;
}
// Generate webhook ID (unique per server)
$baseUrl = rtrim((string) config('app.url'), '/');
$webhookMap = [
'bounced' => $baseUrl . '/webhooks/mailgun/bounce',
'failed' => $baseUrl . '/webhooks/mailgun/bounce',
'opened' => $baseUrl . '/webhooks/mailgun/open',
'clicked' => $baseUrl . '/webhooks/mailgun/click',
];
$results = [];
foreach ($webhookMap as $eventName => $url) {
$results[$eventName] = $webhookService->createOrUpdateWebhook(
$domain,
$apiKey,
$eventName,
$url
);
}
$settings['mailgun_webhooks'] = [
'domain' => $domain,
'configured_at' => now()->toIso8601String(),
'events' => array_keys($webhookMap),
'urls' => $webhookMap,
];
$deliveryServer->update(['settings' => $settings]);
Log::info("Mailgun webhooks configured", [
'server_id' => $deliveryServer->id,
'domain' => $domain,
'success' => collect($results)->every(fn ($r) => ($r['success'] ?? false) === true),
]);
} catch (\Exception $e) {
Log::error("Exception setting up Mailgun webhook: " . $e->getMessage(), [
'server_id' => $deliveryServer->id,
'trace' => $e->getTraceAsString(),
]);
}
}
/**
* Delete Mailgun webhook when server is deleted.
*/
protected function deleteMailgunWebhook(DeliveryServer $deliveryServer): void
{
try {
$settings = $deliveryServer->settings ?? [];
$webhookInfo = $settings['mailgun_webhooks'] ?? null;
$webhookService = app(MailgunWebhookService::class);
$domain = $webhookInfo['domain'] ?? $settings['domain'] ?? null;
$apiKey = $settings['secret'] ?? config('services.mailgun.secret');
if (!$domain || !$apiKey) {
return;
}
$events = $webhookInfo['events'] ?? ['bounced', 'failed', 'opened', 'clicked'];
foreach ($events as $eventName) {
if (!is_string($eventName) || trim($eventName) === '') {
continue;
}
$webhookService->deleteWebhook($domain, $apiKey, trim($eventName));
}
Log::info("Mailgun webhooks deleted", [
'server_id' => $deliveryServer->id,
'domain' => $domain,
]);
} catch (\Exception $e) {
Log::error("Exception deleting Mailgun webhook: " . $e->getMessage(), [
'server_id' => $deliveryServer->id,
]);
}
}
/**
* Setup Resend webhook for a delivery server via Resend API.
*/
protected function setupResendWebhook(DeliveryServer $deliveryServer): void
{
try {
$settings = $deliveryServer->settings ?? [];
$apiKey = $settings['api_key'] ?? null;
$endpoint = rtrim($settings['endpoint'] ?? 'https://api.resend.com', '/');
if (!$apiKey) {
Log::warning('Cannot setup Resend webhook: missing API key', [
'server_id' => $deliveryServer->id,
]);
return;
}
$baseUrl = rtrim((string) config('app.url'), '/');
// Resend requires HTTPS webhook URLs
$webhookUrl = str_replace('http://', 'https://', $baseUrl) . '/webhooks/resend';
$events = [
'email.sent',
'email.delivered',
'email.delivery_delayed',
'email.bounced',
'email.opened',
'email.clicked',
'email.complained',
];
$client = Http::timeout(20)
->withHeaders([
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
]);
// List existing webhooks to find one with matching URL
$existingWebhookId = null;
$listResponse = $client->get($endpoint . '/webhooks');
if ($listResponse->successful()) {
$webhooks = $listResponse->json('data') ?? [];
foreach ($webhooks as $webhook) {
$webhookUrlField = $webhook['url'] ?? $webhook['endpoint'] ?? '';
if ($webhookUrlField === $webhookUrl) {
$existingWebhookId = $webhook['id'] ?? null;
break;
}
}
}
$payload = [
'endpoint' => $webhookUrl,
'events' => $events,
];
if ($existingWebhookId) {
$response = $client->patch($endpoint . '/webhooks/' . $existingWebhookId, $payload);
} else {
$response = $client->post($endpoint . '/webhooks', $payload);
}
if (!$response->successful()) {
Log::error('Failed to ' . ($existingWebhookId ? 'update' : 'create') . ' Resend webhook', [
'server_id' => $deliveryServer->id,
'status' => $response->status(),
'body' => $response->body(),
]);
return;
}
$result = $response->json();
$webhookId = $result['id'] ?? $existingWebhookId ?? null;
$webhookSecret = $result['secret'] ?? null;
if ($webhookId) {
$settings['resend_webhook_id'] = $webhookId;
}
if ($webhookSecret) {
$settings['webhook_secret'] = $webhookSecret;
}
$settings['resend_webhook_url'] = $webhookUrl;
$settings['resend_webhook_events'] = $events;
$settings['resend_webhook_configured_at'] = now()->toIso8601String();
$deliveryServer->update(['settings' => $settings]);
Log::info('Resend webhook ' . ($existingWebhookId ? 'updated' : 'created'), [
'server_id' => $deliveryServer->id,
'webhook_id' => $webhookId,
]);
} catch (\Exception $e) {
Log::error('Exception setting up Resend webhook: ' . $e->getMessage(), [
'server_id' => $deliveryServer->id,
'trace' => $e->getTraceAsString(),
]);
}
}
/**
* Delete Resend webhook when server is deleted.
*/
protected function deleteResendWebhook(DeliveryServer $deliveryServer): void
{
try {
$settings = $deliveryServer->settings ?? [];
$webhookId = $settings['resend_webhook_id'] ?? null;
$apiKey = $settings['api_key'] ?? null;
$endpoint = rtrim($settings['endpoint'] ?? 'https://api.resend.com', '/');
if (!$webhookId || !$apiKey) {
return;
}
$response = Http::timeout(20)
->withHeaders([
'Authorization' => 'Bearer ' . $apiKey,
'Accept' => 'application/json',
])
->delete($endpoint . '/webhooks/' . $webhookId);
if (!$response->successful() && $response->status() !== 404) {
Log::warning('Failed to delete Resend webhook', [
'server_id' => $deliveryServer->id,
'webhook_id' => $webhookId,
'status' => $response->status(),
]);
return;
}
Log::info('Resend webhook deleted', [
'server_id' => $deliveryServer->id,
'webhook_id' => $webhookId,
]);
} catch (\Exception $e) {
Log::error('Exception deleting Resend webhook: ' . $e->getMessage(), [
'server_id' => $deliveryServer->id,
]);
}
}
/**
* Test SMTP/API connection and send test email.
*/
public function testConnection(array $data): array
{
Log::info('Starting SMTP/API connection test', [
'test_type' => $data['test_type'] ?? 'unknown',
'server_id' => $data['server_id'] ?? null,
'type' => $data['type'] ?? null,
]);
$testTimeoutSeconds = 8;
$previousSocketTimeout = ini_get('default_socket_timeout');
if ($previousSocketTimeout !== false) {
@ini_set('default_socket_timeout', (string) $testTimeoutSeconds);
}
$server = null;
try {
if ($data['test_type'] === 'server') {
$server = DeliveryServer::findOrFail($data['server_id']);
Log::info('Using existing delivery server', [
'server_id' => $server->id,
'server_name' => $server->name,
'server_type' => $server->type,
'hostname' => $server->hostname,
'port' => $server->port,
]);
} else {
Log::info('Using manual configuration', [
'type' => $data['type'] ?? 'unknown',
'hostname' => $data['hostname'] ?? null,
'port' => $data['port'] ?? null,
]);
}
// Configure mail based on server or manual settings
if ($server) {
$this->configureMailFromServer($server);
Log::info('Configuring mail from server');
// API providers reject mail unless the From domain is verified on the
// provider account. Falling back to the global MAIL_FROM_ADDRESS (which
// usually belongs to a different domain) produces a confusing rejection,
// so require an explicit From Email on these servers instead.
if (in_array($server->type, ['resend', 'sparkpost', 'mailgun', 'sendgrid', 'postmark'], true)
&& (!is_string($server->from_email) || trim((string) $server->from_email) === '')) {
throw new \Exception('This ' . ucfirst($server->type) . ' server has no From Email set. Edit the server and set a From Email on a domain verified in your ' . ucfirst($server->type) . ' account.');
}
$fromEmail = $server->from_email ?? config('mail.from.address');
$fromName = $server->from_name ?? config('mail.from.name');
} else {
Log::info('Configuring mail from manual settings');
$manualType = (string) ($data['type'] ?? 'smtp');
$manualEncryption = $this->normalizeEncryptionValue($data['encryption'] ?? null, $manualType);
if ($manualEncryption === 'none') {
$manualEncryption = null;
}
$data['encryption'] = $manualEncryption;
$this->configureMailFromManual($data);
$fromEmail = $data['from_email'] ?? config('mail.from.address');
$fromName = $data['from_name'] ?? config('mail.from.name');
}
if (!is_string($fromEmail) || trim($fromEmail) === '') {
throw new \Exception('Sender email is not configured. Please set MAIL_FROM_ADDRESS (or configure From Email on the selected delivery server).');
}
// Log mail configuration (without sensitive data)
Log::info('Mail configuration set', [
'default_mailer' => config('mail.default'),
'from_email' => $fromEmail,
'from_name' => $fromName,
'to_email' => $data['to_email'],
'subject' => $data['subject'],
'mailgun_domain' => config('services.mailgun.domain'),
'mailgun_secret_exists' => !empty(config('services.mailgun.secret')),
'smtp_host' => config('mail.mailers.smtp.host'),
'smtp_port' => config('mail.mailers.smtp.port'),
]);
try {
if (config('mail.default') === 'smtp') {
$smtpHost = (string) config('mail.mailers.smtp.host');
$smtpPort = (int) config('mail.mailers.smtp.port');
if ($server && in_array($server->type, ['smtp', 'gmail', 'outlook', 'zeptomail', 'mailjet'], true)) {
$smtpHost = (string) ($server->hostname ?? '');
$smtpPort = (int) ($server->port ?? $smtpPort);
}
if (trim($smtpHost) === '') {
throw new \Exception('SMTP preflight failed: host is empty for the selected delivery server. Please set a valid SMTP hostname.');
}
if ($smtpPort <= 0) {
$smtpPort = 587;
}
$resolved = @gethostbynamel($smtpHost);
$resolved = is_array($resolved) ? $resolved : [];
$fp = @fsockopen($smtpHost, $smtpPort > 0 ? $smtpPort : 587, $errno, $errstr, $testTimeoutSeconds);
$tcpConnectOk = is_resource($fp);
if ($tcpConnectOk) {
fclose($fp);
}
Log::info('SMTP preflight check for test email', [
'host' => $smtpHost,
'port' => $smtpPort,
'dns_a' => $resolved,
'tcp_connect_ok' => $tcpConnectOk,
'tcp_errno' => $errno ?? null,
'tcp_errstr' => $errstr ?? null,
]);
if (!$tcpConnectOk) {
Log::warning('SMTP preflight TCP connect failed (non-fatal, will attempt send anyway)', [
'host' => $smtpHost,
'port' => $smtpPort,
'errno' => $errno ?? null,
'errstr' => $errstr ?? null,
]);
}
}
} catch (\Throwable $e) {
Log::warning('SMTP preflight check error (non-fatal)', [
'error' => $e->getMessage(),
]);
}
// Send test email
Log::info('Attempting to send test email');
if ($server && $server->type === 'zeptomail-api') {
$rawBody = (string) ($data['message'] ?? '');
$looksLikeHtml = preg_match('/<\s*\w+[^>]*>/', $rawBody) === 1;
$htmlBody = $looksLikeHtml
? $rawBody
: '<div style="white-space: pre-wrap;">' . nl2br(htmlspecialchars($rawBody, ENT_QUOTES, 'UTF-8')) . '</div>';
$textBody = trim(preg_replace('/\s+/', ' ', strip_tags($rawBody)));
$zepto = app(ZeptoMailApiService::class);
$message = [
'from_email' => (string) $fromEmail,
'from_name' => (string) $fromName,
'to_email' => (string) $data['to_email'],
'subject' => (string) $data['subject'],
'htmlbody' => $htmlBody,
'textbody' => $textBody,
'client_reference' => 'delivery-server-test-' . $server->id,
];
$server->loadMissing('bounceServer');
if (empty(($server->settings ?? [])['bounce_address']) && !empty($server->bounceServer?->username)) {
$message['bounce_address'] = (string) $server->bounceServer->username;
}
$zepto->sendRaw($server, $message);
Log::info('Test email sent successfully (ZeptoMail API)', [
'from' => $fromEmail,
'to' => $data['to_email'],
'subject' => $data['subject'],
]);
return [
'from' => $fromEmail,
'to' => $data['to_email'],
'subject' => $data['subject'],
'server_type' => $server->type,
];
}
if ($server && $server->type === 'emailit') {
$rawBody = (string) ($data['message'] ?? '');
$looksLikeHtml = preg_match('/<\s*\w+[^>]*>/', $rawBody) === 1;
$htmlBody = $looksLikeHtml
? $rawBody
: '<div style="white-space: pre-wrap;">' . nl2br(htmlspecialchars($rawBody, ENT_QUOTES, 'UTF-8')) . '</div>';
$textBody = trim(preg_replace('/\s+/', ' ', strip_tags($rawBody)));
$emailit = app(EmailitApiService::class);
$message = [
'from_email' => (string) $fromEmail,
'from_name' => (string) $fromName,
'subject' => (string) $data['subject'],
'htmlbody' => $htmlBody,
'textbody' => $textBody,
];
$emailit->send($server, $message);
Log::info('Test email sent successfully (Emailit API)', [
'from' => $fromEmail,
'to' => $data['to_email'],
'subject' => $data['subject'],
]);
return [
'from' => $fromEmail,
'to' => $data['to_email'],
'subject' => $data['subject'],
'server_type' => $server->type,
];
}
$isSparkPost = ($server && $server->type === 'sparkpost')
|| (!$server && (($data['type'] ?? null) === 'sparkpost'));
if ($isSparkPost) {
$this->sendSparkPostApiTestEmail($server, $data, (string) $fromEmail, (string) ($fromName ?? ''));
Log::info('Test email sent successfully (SparkPost API)', [
'from' => $fromEmail,
'to' => $data['to_email'],
'subject' => $data['subject'],
]);
return [
'from' => $fromEmail,
'to' => $data['to_email'],
'subject' => $data['subject'],
'server_type' => $server ? $server->type : $data['type'],
'test_type' => $data['test_type'],
'transport' => 'sparkpost-api',
];
}
if ($server) {
$this->configureMailFromServer($server);
}
// Ensure test email fails fast on network issues (avoid hitting PHP max_execution_time)
try {
$smtpMailer = (array) (config('mail.mailers.smtp') ?? []);
$smtpMailer['timeout'] = $testTimeoutSeconds;
\Illuminate\Support\Facades\Config::set('mail.mailers.smtp', $smtpMailer);
} catch (\Throwable $e) {
// ignore
}
try {
app('mail.manager')->forgetMailers();
app('mail.manager')->purge('smtp');
} catch (\Throwable $e) {
Log::warning('Unable to reset cached mailers before SMTP test send', [
'error' => $e->getMessage(),
]);
}
// Apply SSL stream options directly to the Symfony SocketStream.
// Laravel's configureSmtpTransport() ignores the 'stream' config key —
// SSL options must be set on the transport's SocketStream after it is built.
try {
if (!env('MAIL_VERIFY_PEER', true)) {
$builtMailer = app('mail.manager')->mailer(config('mail.default', 'smtp'));
$symfonyTransport = $builtMailer->getSymfonyTransport();
if ($symfonyTransport instanceof \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport) {
$socketStream = $symfonyTransport->getStream();
if ($socketStream instanceof \Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream) {
$socketStream->setStreamOptions([
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
],
]);
Log::info('SSL certificate verification disabled for test SMTP send');
}
}
}
} catch (\Throwable $e) {
Log::warning('Unable to configure SSL stream options for SMTP transport', [
'error' => $e->getMessage(),
]);
}
// Load bounce server if server exists and has bounce_server_id
$bounceServer = null;
if ($server && $server->bounce_server_id) {
$server->load('bounceServer');
$bounceServer = $server->bounceServer;
// Only use bounce server if it's active and has a username (email address)
if ($bounceServer && $bounceServer->isActive() && !empty($bounceServer->username)) {
Log::info('Bounce server found for test email', [
'bounce_server_id' => $bounceServer->id,
'bounce_server_name' => $bounceServer->name,
'bounce_email' => $bounceServer->username,
]);
} else {
$bounceServer = null; // Reset if not active or missing username
}
}
$rawBody = (string) ($data['message'] ?? '');
$looksLikeHtml = preg_match('/<\s*\w+[^>]*>/', $rawBody) === 1;
$htmlBody = $looksLikeHtml
? $rawBody
: '<div style="white-space: pre-wrap;">' . nl2br(htmlspecialchars($rawBody, ENT_QUOTES, 'UTF-8')) . '</div>';
$textBody = trim(preg_replace('/\s+/', ' ', strip_tags($rawBody)));
Mail::send([], [], function ($message) use ($data, $fromEmail, $fromName, $bounceServer, $htmlBody, $textBody) {
$message->to($data['to_email'])
->subject($data['subject'])
->from($fromEmail, $fromName);
// Set Return-Path header if bounce server is configured and active
if ($bounceServer && !empty($bounceServer->username)) {
$message->returnPath($bounceServer->username);
Log::info('Added Return-Path header to test email', [
'return_path' => $bounceServer->username,
]);
}
if (!empty($htmlBody)) {
$message->html($htmlBody);
}
if (!empty($textBody)) {
$message->text($textBody);
}
});
Log::info('Test email sent successfully', [
'from' => $fromEmail,
'to' => $data['to_email'],
'subject' => $data['subject'],
]);
if ($server) {
DeliveryServerLog::create([
'delivery_server_id' => $server->id,
'event' => 'test_sent',
'to_email' => $data['to_email'],
'status' => 'success',
'meta' => ['from' => $fromEmail, 'subject' => $data['subject']],
]);
}
return [
'from' => $fromEmail,
'to' => $data['to_email'],
'subject' => $data['subject'],
'server_type' => $server ? $server->type : $data['type'],
'test_type' => $data['test_type'],
];
} catch (\Swift_TransportException $e) {
Log::error('Swift Transport Exception during email test', [
'error' => $e->getMessage(),
'code' => $e->getCode(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
]);
$this->logDeliveryError($server, $data, 'test_failed', $e->getMessage());
throw new \Exception('SMTP Connection Error: ' . $e->getMessage(), 0, $e);
} catch (\Exception $e) {
Log::error('Exception during email test', [
'error' => $e->getMessage(),
'code' => $e->getCode(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
'mail_config' => [
'default' => config('mail.default'),
'smtp_host' => config('mail.mailers.smtp.host'),
'smtp_port' => config('mail.mailers.smtp.port'),
'smtp_encryption' => config('mail.mailers.smtp.encryption'),
'smtp_username' => config('mail.mailers.smtp.username') ? '***' : null,
],
]);
$this->logDeliveryError($server, $data, 'test_failed', $e->getMessage());
throw $e;
} finally {
if ($previousSocketTimeout !== false) {
@ini_set('default_socket_timeout', (string) $previousSocketTimeout);
}
}
}
/**
* Configure mail settings from delivery server.
*/
public function configureMailFromServer(DeliveryServer $server, ?string $sendingDomainOverride = null): void
{
Log::info('Configuring mail from server', [
'server_type' => $server->type,
'server_id' => $server->id,
'sending_domain_override' => $sendingDomainOverride,
]);
// Forget any previously-resolved mailer so the new config is picked up.
try {
Mail::purge();
} catch (\Throwable $e) {
// Fallback for versions without purge()
}
try {
$systemId = Setting::get('system_smtp_delivery_server_id');
if ($systemId && (int) $systemId === (int) $server->id) {
$this->configureMailFromSystemSmtp();
return;
}
} catch (\Throwable $e) {
// ignore
}
switch ($server->type) {
case 'smtp':
case 'gmail':
case 'outlook':
case 'zeptomail':
if (empty($server->hostname)) {
throw new \RuntimeException('Delivery server "' . $server->name . '" has no hostname configured. Please update the server settings.');
}
$encryption = $this->normalizeEncryptionValue($server->encryption, (string) $server->type);
if ($encryption === 'none') {
$encryption = null;
}
Log::info('Configuring SMTP settings', [
'host' => $server->hostname,
'port' => $server->port ?? 587,
'encryption' => $encryption ?? 'tls',
'username' => $server->username,
'has_password' => !empty($server->password),
]);
Config::set('mail.mailers.smtp', [
'transport' => 'smtp',
'host' => $server->hostname,
'port' => $server->port ?? 587,
'encryption' => $encryption,
'username' => $server->username,
'password' => $server->password,
'stream' => $this->smtpStreamOptions(),
'timeout' => $server->timeout ?? 30,
]);
Config::set('mail.default', 'smtp');
break;
case 'mailgun':
$settings = $server->settings ?? [];
// Check if Mailgun has API credentials (domain and secret)
$domain = $sendingDomainOverride ?: ($settings['domain'] ?? config('services.mailgun.domain'));
$secret = $settings['secret'] ?? $settings['api_key'] ?? config('services.mailgun.secret');
Log::info('Mailgun configuration check', [
'server_id' => $server->id,
'domain' => $domain,
'secret_exists' => !empty($secret),
'hostname' => $server->hostname,
'username' => $server->username,
'password_exists' => !empty($server->password),
'settings_domain' => $settings['domain'] ?? null,
'settings_secret' => $settings['secret'] ?? null,
'settings_api_key' => $settings['api_key'] ?? null,
]);
if (!empty($domain) && !empty($secret)) {
// Use API configuration for Mailgun (prioritize API over SMTP)
if (!class_exists('Symfony\\Component\\Mailer\\Bridge\\Mailgun\\Transport\\MailgunTransportFactory')) {
throw new \Exception('Mailgun mail transport is not installed. Install symfony/mailgun-mailer and symfony/http-client to send via Mailgun API.');
}
Log::info('Configuring Mailgun API settings', [
'domain' => $domain,
'has_secret' => !empty($secret),
'endpoint' => $settings['endpoint'] ?? config('services.mailgun.endpoint', 'api.mailgun.net'),
]);
Config::set('services.mailgun', [
'domain' => $domain,
'secret' => $secret,
'endpoint' => $settings['endpoint'] ?? config('services.mailgun.endpoint', 'api.mailgun.net'),
]);
Config::set('mail.default', 'mailgun');
Log::info('Mailgun configured to use API', [
'mail_default' => config('mail.default'),
'services_mailgun_domain' => config('services.mailgun.domain'),
]);
} elseif (!empty($server->hostname) && !empty($server->username) && !empty($server->password)) {
// Use SMTP configuration for Mailgun as fallback
Log::info('Configuring Mailgun SMTP settings', [
'host' => $server->hostname,
'port' => $server->port ?? 587,
'encryption' => $server->encryption ?? 'tls',
'username' => $server->username,
'has_password' => !empty($server->password),
]);
Config::set('mail.mailers.smtp', [
'transport' => 'smtp',
'host' => $server->hostname,
'port' => $server->port ?? 587,
'encryption' => $server->encryption ?? 'tls',
'username' => $server->username,
'password' => $server->password,
'stream' => $this->smtpStreamOptions(),
'timeout' => $server->timeout ?? 30,
]);
Config::set('mail.default', 'smtp');
Log::info('Mailgun configured to use SMTP', [
'mail_default' => config('mail.default'),
]);
} else {
Log::warning('Mailgun configuration incomplete', [
'has_domain' => !empty($domain),
'has_secret' => !empty($secret),
'has_hostname' => !empty($server->hostname),
'has_username' => !empty($server->username),
'has_password' => !empty($server->password),
]);
throw new \Exception('Mailgun configuration incomplete: Provide either API credentials (domain and API key) or SMTP credentials (hostname, username, password).');
}
break;
case 'sendgrid':
$settings = $server->settings ?? [];
Config::set('services.sendgrid', [
'api_key' => $settings['api_key'] ?? config('services.sendgrid.api_key'),
]);
if (!config('mail.mailers.sendgrid')) {
Config::set('mail.mailers.sendgrid', ['transport' => 'sendgrid']);
}
Config::set('mail.default', 'sendgrid');
break;
case 'postmark':
$settings = $server->settings ?? [];
Config::set('services.postmark', [
'token' => $settings['token'] ?? config('services.postmark.token'),
]);
Config::set('mail.default', 'postmark');
break;
case 'amazon-ses':
$settings = $server->settings ?? [];
Config::set('services.ses', [
'key' => $settings['key'] ?? config('services.ses.key'),
'secret' => $settings['secret'] ?? config('services.ses.secret'),
'region' => $settings['region'] ?? config('services.ses.region', 'us-east-1'),
]);
// Use custom SES transport that supports ReplyToAddresses
Config::set('mail.mailers.ses-reply-to', [
'transport' => 'ses-reply-to',
'key' => $settings['key'] ?? config('services.ses.key'),
'secret' => $settings['secret'] ?? config('services.ses.secret'),
'region' => $settings['region'] ?? config('services.ses.region', 'us-east-1'),
'options' => $settings['options'] ?? [],
]);
Config::set('mail.default', 'ses-reply-to');
break;
case 'sparkpost':
$settings = $server->settings ?? [];
Log::info('SparkPost server settings', [
'server_id' => $server->id,
'settings_keys' => array_keys($settings),
'has_secret' => isset($settings['secret']),
'has_api_key' => isset($settings['api_key']),
'has_password' => !empty($server->password),
]);
$secret = $settings['secret']
?? $settings['api_key']
?? $server->password
?? config('services.sparkpost.secret');
$endpoint = $settings['endpoint']
?? $server->hostname
?? config('services.sparkpost.endpoint');
Log::info('Configuring SparkPost settings', [
'endpoint' => $endpoint,
'has_secret' => !empty($secret),
]);
if (empty($secret)) {
throw new \Exception('SparkPost configuration incomplete: API key is required.');
}
Config::set('services.sparkpost', [
'secret' => $secret,
'endpoint' => $endpoint,
]);
Config::set('mail.default', 'sparkpost');
break;
case 'mailjet':
$settings = $server->settings ?? [];
$apiKey = $settings['key'] ?? $server->username;
$apiSecret = $settings['secret'] ?? $server->password;
Log::info('Configuring Mailjet settings from server', [
'host' => $server->hostname ?? 'in-v3.mailjet.com',
'port' => $server->port ?? 587,
'encryption' => $server->encryption ?? 'tls',
'has_key' => !empty($apiKey),
'has_secret' => !empty($apiSecret),
]);
if (empty($apiKey) || empty($apiSecret)) {
Log::warning('Mailjet configuration incomplete', [
'has_key' => !empty($apiKey),
'has_secret' => !empty($apiSecret),
]);
throw new \Exception('Mailjet configuration incomplete: API Key and API Secret are required.');
}
// Mailjet uses SMTP with API key/secret as username/password
Config::set('mail.mailers.smtp', [
'transport' => 'smtp',
'host' => $server->hostname ?? 'in-v3.mailjet.com',
'port' => $server->port ?? 587,
'encryption' => $server->encryption ?? 'tls',
'username' => $apiKey,
'password' => $apiSecret,
'stream' => $this->smtpStreamOptions(),
'timeout' => $server->timeout ?? 30,
]);
Config::set('mail.default', 'smtp');
break;
case 'resend':
$settings = (array) ($server->settings ?? []);
$apiKey = $settings['api_key']
?? $settings['secret']
?? $settings['key']
?? $server->password
?? config('services.resend.api_key');
if (empty($apiKey)) {
throw new \Exception('Resend configuration incomplete: API key is required.');
}
$endpoint = $settings['endpoint'] ?? $server->hostname ?? 'https://api.resend.com';
Config::set('services.resend', [
'api_key' => $apiKey,
'endpoint' => $endpoint,
]);
Config::set('mail.mailers.resend', [
'transport' => 'resend',
'api_key' => $apiKey,
'endpoint' => $endpoint,
]);
Config::set('mail.default', 'resend');
break;
case 'gmail-api':
$settings = (array) ($server->settings ?? []);
Config::set('mail.mailers.gmail-api', [
'transport' => 'gmail-api',
'client_id' => $settings['client_id'] ?? '',
'client_secret' => $settings['client_secret'] ?? '',
'refresh_token' => $settings['refresh_token'] ?? '',
]);
Config::set('mail.default', 'gmail-api');
break;
case 'sendmail':
Log::info('Configuring Sendmail');
Config::set('mail.default', 'sendmail');
break;
case 'emailit':
Log::info('Emailit API server configured (no mailer transport required)');
break;
default:
throw new \RuntimeException('Unsupported delivery server type "' . $server->type . '" for server "' . $server->name . '".');
}
// Log final mail configuration
Log::info('Mail configuration completed', [
'default_mailer' => config('mail.default'),
'smtp_host' => config('mail.mailers.smtp.host'),
'smtp_port' => config('mail.mailers.smtp.port'),
]);
}
/**
* Log a delivery error to the delivery_server_logs table.
*/
protected function logDeliveryError(?DeliveryServer $server, array $data, string $event, string $errorMessage): void
{
if (!$server) {
return;
}
try {
$category = DeliveryServerLog::categorizeError($errorMessage);
DeliveryServerLog::create([
'delivery_server_id' => $server->id,
'event' => $event,
'to_email' => $data['to_email'] ?? null,
'status' => 'failed',
'error_code' => $this->extractErrorCode($errorMessage),
'error_message' => mb_substr($errorMessage, 0, 1000),
'diagnostic' => $errorMessage,
'error_category' => $category,
'meta' => [
'from' => $data['from_email'] ?? ($server->from_email ?? null),
'subject' => $data['subject'] ?? null,
],
]);
} catch (\Throwable $e) {
Log::warning('Failed to log delivery server error', [
'server_id' => $server->id,
'error' => $e->getMessage(),
]);
}
}
/**
* Extract SMTP error code from an error message string.
*/
protected function extractErrorCode(string $message): ?string
{
if (preg_match('/\b(\d{3}[-\s]\d\.\d\.\d+)\b/', $message, $m)) {
return $m[1];
}
if (preg_match('/\b(\d{3})\b/', $message, $m)) {
return $m[1];
}
return null;
}
protected function smtpStreamOptions(): array
{
return [
'ssl' => [
'verify_peer' => (bool) env('MAIL_VERIFY_PEER', true),
'verify_peer_name' => (bool) env('MAIL_VERIFY_PEER_NAME', true),
'allow_self_signed' => (bool) env('MAIL_ALLOW_SELF_SIGNED', false),
],
];
}
public function hasSystemSmtpConfigured(): bool
{
$existing = $this->getStoredSystemSmtpDeliveryServer();
if ($existing) {
return true;
}
$host = env('MAIL_HOST');
if (!is_string($host)) {
return false;
}
return trim($host) !== '';
}
public function getStoredSystemSmtpDeliveryServer(): ?DeliveryServer
{
try {
$existingId = Setting::get('system_smtp_delivery_server_id');
if ($existingId && DeliveryServer::query()->whereKey((int) $existingId)->exists()) {
return DeliveryServer::query()->whereKey((int) $existingId)->first();
}
} catch (\Throwable $e) {
// ignore
}
return DeliveryServer::query()
->whereNull('customer_id')
->where('name', 'System (SMTP)')
->orderBy('id')
->first();
}
public function getSystemSmtpServerStub(): object
{
$systemServer = $this->getOrCreateSystemSmtpDeliveryServer();
$host = (string) env('MAIL_HOST', '');
$port = (int) env('MAIL_PORT', 587);
$encryption = (string) env('MAIL_ENCRYPTION', 'tls');
$username = env('MAIL_USERNAME');
$fromEmail = Setting::get('from_email', env('MAIL_FROM_ADDRESS'));
$fromName = Setting::get('from_name', env('MAIL_FROM_NAME', env('APP_NAME', 'MailPurse')));
return (object) [
'is_system' => true,
'id' => $systemServer?->id,
'name' => 'System (SMTP)',
'type' => 'smtp',
'status' => 'active',
'hostname' => $host,
'port' => $port > 0 ? $port : 587,
'encryption' => $encryption,
'username' => is_string($username) ? $username : null,
'from_email' => is_string($fromEmail) ? $fromEmail : null,
'from_name' => is_string($fromName) ? $fromName : null,
'monthly_quota' => 0,
'is_primary' => false,
'locked' => true,
'use_for' => (bool) ($systemServer?->use_for ?? false),
'use_for_transactional' => (bool) ($systemServer?->use_for_transactional ?? false),
'use_for_email_to_list' => (bool) ($systemServer?->use_for_email_to_list ?? false),
];
}
public function getOrCreateSystemSmtpDeliveryServer(): ?DeliveryServer
{
$existing = $this->getStoredSystemSmtpDeliveryServer();
if ($existing) {
try {
Setting::set('system_smtp_delivery_server_id', (int) $existing->id, 'email', 'integer');
} catch (\Throwable $e) {
// ignore
}
return $existing;
}
if (!$this->hasSystemSmtpConfigured()) {
return null;
}
$server = DeliveryServer::query()->create([
'customer_id' => null,
'name' => 'System (SMTP)',
'type' => 'smtp',
'status' => 'active',
'hostname' => (string) env('MAIL_HOST', ''),
'port' => (int) env('MAIL_PORT', 587),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'encryption' => $this->normalizeEncryptionValue(env('MAIL_ENCRYPTION', 'tls'), 'smtp'),
'from_email' => Setting::get('from_email', env('MAIL_FROM_ADDRESS')),
'from_name' => Setting::get('from_name', env('MAIL_FROM_NAME', env('APP_NAME', 'MailPurse'))),
'timeout' => (int) env('MAIL_TIMEOUT', 30),
'max_connection_messages' => 100,
'second_quota' => 0,
'minute_quota' => 0,
'hourly_quota' => 0,
'daily_quota' => 0,
'monthly_quota' => 0,
'pause_after_send' => 0,
'settings' => ['system_env_seeded' => true],
'locked' => true,
'use_for' => false,
'use_for_email_to_list' => false,
'use_for_transactional' => false,
]);
try {
Setting::set('system_smtp_delivery_server_id', (int) $server->id, 'email', 'integer');
} catch (\Throwable $e) {
// ignore
}
return $server;
}
/**
* Update system server configuration from environment variables.
*/
public function updateSystemServerFromEnv(DeliveryServer $server): void
{
if (!$this->hasSystemSmtpConfigured()) {
return;
}
$host = (string) env('MAIL_HOST', '');
$port = (int) env('MAIL_PORT', 587);
$encryption = $this->normalizeEncryptionValue(env('MAIL_ENCRYPTION', 'tls'), 'smtp');
$username = env('MAIL_USERNAME');
$server->update([
'hostname' => $host,
'port' => $port > 0 ? $port : 587,
'encryption' => $encryption,
'username' => is_string($username) ? $username : null,
]);
}
public function configureMailFromSystemSmtp(?DeliveryServer $server = null): void
{
$server ??= $this->getStoredSystemSmtpDeliveryServer();
if ($server && is_string($server->hostname) && trim($server->hostname) !== '') {
$encryption = $server->encryption;
if (!is_string($encryption) || trim($encryption) === '' || strtolower(trim($encryption)) === 'none') {
$encryption = null;
}
Config::set('mail.mailers.smtp', [
'transport' => 'smtp',
'host' => (string) $server->hostname,
'port' => (int) ($server->port ?? 587),
'encryption' => $encryption,
'username' => $server->username,
'password' => $server->password,
'stream' => $this->smtpStreamOptions(),
'timeout' => (int) ($server->timeout ?? 30),
]);
Config::set('mail.default', 'smtp');
return;
}
$host = env('MAIL_HOST');
if (!is_string($host) || trim($host) === '') {
return;
}
$encryption = env('MAIL_ENCRYPTION', 'tls');
if (!is_string($encryption) || trim($encryption) === '' || strtolower(trim($encryption)) === 'none') {
$encryption = null;
}
Config::set('mail.mailers.smtp', [
'transport' => 'smtp',
'host' => (string) $host,
'port' => (int) env('MAIL_PORT', 587),
'encryption' => $encryption,
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'stream' => $this->smtpStreamOptions(),
'timeout' => (int) env('MAIL_TIMEOUT', 30),
]);
Config::set('mail.default', 'smtp');
}
/**
* Normalize an encryption value into one of the supported database values.
*/
protected function normalizeEncryptionValue(mixed $value, ?string $type = null): string
{
if (is_string($value)) {
$value = strtolower(trim($value));
if (in_array($value, ['ssl', 'tls', 'none'], true)) {
return $value;
}
}
$type = strtolower(trim((string) $type));
if ($type === 'sendmail') {
return 'none';
}
return 'tls';
}
/**
* Configure mail settings from manual input.
*/
protected function configureMailFromManual(array $data): void
{
Log::info('Configuring mail from manual input', [
'type' => $data['type'] ?? 'unknown',
]);
$encryption = $this->normalizeEncryptionValue($data['encryption'] ?? null, (string) ($data['type'] ?? 'smtp'));
if ($encryption === 'none') {
$encryption = null;
}
switch ($data['type']) {
case 'smtp':
case 'gmail':
case 'outlook':
case 'zeptomail':
Log::info('Configuring manual SMTP settings', [
'host' => $data['hostname'] ?? null,
'port' => $data['port'] ?? 587,
'encryption' => $encryption ?? 'tls',
'username' => $data['username'] ?? null,
'has_password' => !empty($data['password']),
]);
Config::set('mail.mailers.smtp', [
'transport' => 'smtp',
'host' => $data['hostname'],
'port' => $data['port'] ?? 587,
'encryption' => $encryption,
'username' => $data['username'],
'password' => $data['password'],
'stream' => $this->smtpStreamOptions(),
'timeout' => 30,
]);
Config::set('mail.default', 'smtp');
break;
case 'mailgun':
// Check if API credentials are provided (domain and api_key)
// or SMTP credentials (hostname, username, password)
$hasApiCredentials = (!empty($data['hostname']) || !empty($data['api_hostname'])) && !empty($data['api_key']);
$hasSmtpCredentials = !empty($data['hostname']) && !empty($data['username']) && !empty($data['password']);
if ($hasApiCredentials) {
// Use API configuration for Mailgun (prioritize API over SMTP)
$domain = $data['hostname'] ?? $data['api_hostname'] ?? config('services.mailgun.domain');
$secret = $data['api_key'] ?? config('services.mailgun.secret');
Log::info('Configuring Mailgun via API', [
'domain' => $domain,
'has_secret' => !empty($secret),
]);
Config::set('services.mailgun', [
'domain' => $domain,
'secret' => $secret,
]);
Config::set('mail.default', 'mailgun');
} elseif ($hasSmtpCredentials) {
// Use SMTP configuration for Mailgun as fallback
Log::info('Configuring Mailgun via SMTP', [
'host' => $data['hostname'],
'port' => $data['port'] ?? 587,
'username' => $data['username'],
'has_password' => !empty($data['password']),
]);
Config::set('mail.mailers.smtp', [
'transport' => 'smtp',
'host' => $data['hostname'],
'port' => $data['port'] ?? 587,
'encryption' => $encryption,
'username' => $data['username'],
'password' => $data['password'],
'stream' => $this->smtpStreamOptions(),
'timeout' => 30,
]);
Config::set('mail.default', 'smtp');
} else {
Log::warning('Mailgun configuration incomplete', [
'has_smtp' => $hasSmtpCredentials,
'has_api' => $hasApiCredentials,
]);
throw new \Exception('Mailgun configuration incomplete. Provide either API credentials (domain and API key) or SMTP credentials (hostname, username, password).');
}
break;
case 'sendgrid':
Config::set('services.sendgrid', [
'api_key' => $data['api_key'] ?? config('services.sendgrid.api_key'),
]);
if (!config('mail.mailers.sendgrid')) {
Config::set('mail.mailers.sendgrid', ['transport' => 'sendgrid']);
}
Config::set('mail.default', 'sendgrid');
break;
case 'postmark':
Config::set('services.postmark', [
'token' => $data['api_key'] ?? config('services.postmark.token'),
]);
Config::set('mail.default', 'postmark');
break;
case 'amazon-ses':
Config::set('services.ses', [
'key' => $data['api_key'] ?? config('services.ses.key'),
'secret' => $data['api_secret'] ?? config('services.ses.secret'),
'region' => $data['hostname'] ?? $data['api_hostname'] ?? config('services.ses.region', 'us-east-1'),
]);
Config::set('mail.default', 'ses');
break;
case 'sparkpost':
$secret = $data['api_key']
?? $data['api_secret']
?? config('services.sparkpost.secret');
$endpoint = $data['api_hostname']
?? $data['hostname']
?? config('services.sparkpost.endpoint');
Log::info('Configuring manual SparkPost settings', [
'endpoint' => $endpoint,
'has_secret' => !empty($secret),
]);
if (empty($secret)) {
throw new \Exception('SparkPost configuration incomplete: API key is required.');
}
Config::set('services.sparkpost', [
'secret' => $secret,
'endpoint' => $endpoint,
]);
Config::set('mail.default', 'sparkpost');
break;
case 'mailjet':
Log::info('Configuring manual Mailjet settings', [
'host' => $data['hostname'] ?? 'in-v3.mailjet.com',
'port' => $data['port'] ?? 587,
'has_key' => !empty($data['api_key']),
'has_secret' => !empty($data['api_secret']),
]);
// Mailjet uses SMTP with API key/secret as username/password
Config::set('mail.mailers.smtp', [
'transport' => 'smtp',
'host' => $data['hostname'] ?? 'in-v3.mailjet.com',
'port' => $data['port'] ?? 587,
'encryption' => $encryption,
'username' => $data['api_key'] ?? $data['username'],
'password' => $data['api_secret'] ?? $data['password'],
'stream' => $this->smtpStreamOptions(),
'timeout' => 30,
]);
Config::set('mail.default', 'smtp');
break;
case 'sendmail':
Log::info('Configuring manual Sendmail');
Config::set('mail.default', 'sendmail');
break;
}
}
/**
* Send verification email to delivery server's SMTP account email (username).
* Uses the delivery server's own SMTP configuration to verify it can send emails.
*/
public function sendVerificationEmail(DeliveryServer $deliveryServer): bool
{
try {
// Use username as the SMTP account email
$smtpEmail = $deliveryServer->username;
// Only send if username is a valid email address
if (empty($smtpEmail) || !filter_var($smtpEmail, FILTER_VALIDATE_EMAIL)) {
Log::warning("Cannot send verification email: username is not a valid email", [
'server_id' => $deliveryServer->id,
'username' => $smtpEmail,
]);
return false;
}
// Only send for SMTP-like servers (not sendmail, API-based servers, etc.)
if (!in_array($deliveryServer->type, ['smtp', 'gmail', 'outlook'], true)) {
Log::info("Skipping verification email for non-SMTP server", [
'server_id' => $deliveryServer->id,
'type' => $deliveryServer->type,
]);
return false;
}
// Validate required SMTP settings
if (empty($deliveryServer->hostname) || empty($deliveryServer->username) || empty($deliveryServer->password)) {
Log::warning("Cannot send verification email: missing SMTP configuration", [
'server_id' => $deliveryServer->id,
'has_hostname' => !empty($deliveryServer->hostname),
'has_username' => !empty($deliveryServer->username),
'has_password' => !empty($deliveryServer->password),
]);
return false;
}
// Generate verification token if not exists
if (!$deliveryServer->verification_token) {
$deliveryServer->generateVerificationToken();
$deliveryServer->refresh();
}
// Refresh to get the latest token
$deliveryServer->refresh();
// Configure mailer using THIS delivery server's settings
$mailerName = 'verification_' . $deliveryServer->id;
$port = $deliveryServer->port ?? 587;
$encryption = $deliveryServer->encryption ?? 'tls';
$scheme = null;
if ($encryption === 'none') {
$encryption = null;
$scheme = 'smtp';
}
Config::set("mail.mailers.{$mailerName}", [
'transport' => 'smtp',
'scheme' => $scheme,
'host' => $deliveryServer->hostname,
'port' => $port,
'username' => $deliveryServer->username,
'password' => $deliveryServer->password,
'encryption' => $encryption,
'stream' => $this->smtpStreamOptions(),
'timeout' => $deliveryServer->timeout ?? 30,
]);
Log::info("Attempting to send verification email using delivery server's own SMTP config", [
'server_id' => $deliveryServer->id,
'email' => $smtpEmail,
'hostname' => $deliveryServer->hostname,
'port' => $deliveryServer->port,
'encryption' => $deliveryServer->encryption,
'token' => substr($deliveryServer->verification_token, 0, 10) . '...',
]);
try {
$host = (string) $deliveryServer->hostname;
$portForLog = (int) ($deliveryServer->port ?? 0);
$resolved = @gethostbynamel($host);
$resolved = is_array($resolved) ? $resolved : [];
$fp = @fsockopen($host, $portForLog > 0 ? $portForLog : 587, $errno, $errstr, 6);
if (is_resource($fp)) {
fclose($fp);
}
Log::info('SMTP preflight check for verification email', [
'server_id' => $deliveryServer->id,
'host' => $host,
'port' => $portForLog,
'dns_a' => $resolved,
'tcp_connect_ok' => is_resource($fp),
'tcp_errno' => $errno ?? null,
'tcp_errstr' => $errstr ?? null,
]);
} catch (\Throwable $e) {
Log::info('SMTP preflight check failed (non-fatal)', [
'server_id' => $deliveryServer->id,
'error' => $e->getMessage(),
]);
}
// Use the delivery server's own mailer configuration
Mail::mailer($mailerName)
->to($smtpEmail)
->send(new DeliveryServerVerificationMailable($deliveryServer));
Log::info("Verification email sent successfully using delivery server's SMTP", [
'server_id' => $deliveryServer->id,
'email' => $smtpEmail,
'hostname' => $deliveryServer->hostname,
]);
return true;
} catch (\Symfony\Component\Mailer\Exception\TransportExceptionInterface $e) {
Log::error("SMTP transport error sending verification email: " . $e->getMessage(), [
'server_id' => $deliveryServer->id,
'email' => $deliveryServer->username,
'hostname' => $deliveryServer->hostname,
'error_code' => $e->getCode(),
'exception' => get_class($e),
]);
return false;
} catch (\Swift_TransportException $e) {
Log::error("SMTP Transport error sending verification email: " . $e->getMessage(), [
'server_id' => $deliveryServer->id,
'email' => $deliveryServer->username,
'hostname' => $deliveryServer->hostname,
'error_code' => $e->getCode(),
'exception' => get_class($e),
]);
return false;
} catch (\Exception $e) {
Log::error("Failed to send verification email for delivery server: " . $e->getMessage(), [
'server_id' => $deliveryServer->id,
'email' => $deliveryServer->username,
'hostname' => $deliveryServer->hostname,
'exception' => get_class($e),
'file' => $e->getFile(),
'line' => $e->getLine(),
]);
return false;
}
}
/**
* Verify delivery server using token.
*/
public function verify(string $token): ?DeliveryServer
{
$deliveryServer = DeliveryServer::where('verification_token', $token)
->whereNull('verified_at')
->first();
if (!$deliveryServer) {
return null;
}
// Check if token is expired (7 days)
$createdAt = $deliveryServer->created_at;
if ($createdAt && $createdAt->addDays(7)->isPast()) {
Log::warning("Verification token expired", [
'server_id' => $deliveryServer->id,
]);
return null;
}
$deliveryServer->markAsVerified();
Log::info("Delivery server verified", [
'server_id' => $deliveryServer->id,
]);
return $deliveryServer;
}
}