string|null]. * Throws on transport errors so the Job can retry. */ public function sendFromLog(EmailLog $log): array { // Resolve sending profile $profile = null; if ($log->mail_profile_id) { $profile = MailProfile::query()->find($log->mail_profile_id); } if (! $profile) { $profile = MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first(); } $embed = $log->embed_mode ?: 'base64'; $subject = (string) $log->subject; $html = (string) optional($log->body)->body_html ?? ''; $text = (string) optional($log->body)->body_text ?? ''; // Inline CSS and handle images similarly to controller if ($html !== '') { if ($embed === 'base64') { try { $imageInliner = app(\App\Services\EmailImageInliner::class); $html = $imageInliner->inline($html); } catch (\Throwable $e) { } } else { // Best effort absolutize /storage URLs using app.url $base = (string) (config('app.asset_url') ?: config('app.url')); $host = $base !== '' ? rtrim($base, '/') : null; if ($host) { $html = preg_replace_callback('#]+)src=["\']([^"\']+)["\']([^>]*)>#i', function (array $m) use ($host) { $before = $m[1] ?? ''; $src = $m[2] ?? ''; $after = $m[3] ?? ''; $path = $src; if (preg_match('#^https?://#i', $src)) { $parts = parse_url($src); $path = $parts['path'] ?? ''; if (! preg_match('#^/?storage/#i', (string) $path)) { return $m[0]; } } else { if (! preg_match('#^/?storage/#i', (string) $path)) { return $m[0]; } } $rel = '/'.ltrim(preg_replace('#^/?storage/#i', 'storage/', (string) $path), '/'); $abs = rtrim($host, '/').$rel; return ''; }, $html); } } try { $inliner = new CssToInlineStyles; $html = $inliner->convert($html); } catch (\Throwable $e) { } } // Transport setup (Symfony Mailer preferred when profile exists) $messageId = null; if ($profile) { $host = $profile->host; $port = (int) ($profile->port ?: 587); $encryption = $profile->encryption ?: 'tls'; $username = $profile->username ?: ''; $password = (string) ($profile->decryptPassword() ?? ''); $scheme = $encryption === 'ssl' ? 'smtps' : 'smtp'; $query = $encryption === 'tls' ? '?encryption=tls' : ''; $dsn = sprintf('%s://%s:%s@%s:%d%s', $scheme, rawurlencode($username), rawurlencode($password), $host, $port, $query); $transport = Transport::fromDsn($dsn); $mailer = new SymfonyMailer($transport); // Resolve a valid From address without falling back to to_email $fromAddrCandidates = [ (string) ($log->from_email ?? ''), (string) ($profile->from_address ?? ''), (string) ($username ?? ''), (string) (config('mail.from.address') ?? ''), ]; $fromAddr = ''; foreach ($fromAddrCandidates as $cand) { $cand = trim($cand); if ($cand !== '' && filter_var($cand, FILTER_VALIDATE_EMAIL)) { $fromAddr = $cand; break; } } $fromName = (string) ($log->from_name ?: ($profile->from_name ?: (config('mail.from.name') ?? config('app.name') ?? ''))); // Fallback From if still empty: use a no-reply at app host if ($fromAddr === '') { $appUrl = (string) (config('app.url') ?? ''); $hostPart = ''; if ($appUrl !== '') { $parsed = @parse_url($appUrl); $hostPart = is_array($parsed) ? (string) ($parsed['host'] ?? '') : ''; } $domain = $hostPart !== '' ? $hostPart : 'localhost.localdomain'; $fromAddr = 'no-reply@'.$domain; } // Build email with safe Address instances (Symfony Address does not allow null name) $fromAddress = $fromName !== '' ? new Address($fromAddr, $fromName) : new Address($fromAddr); $email = (new Email) ->from($fromAddress) ->subject($subject); // Address recipients: prefer multi-recipient list; fall back to validated single address $toList = (array) ($log->to_recipients ?? []); $addresses = []; foreach ($toList as $addr) { $addr = trim((string) $addr); if ($addr !== '' && filter_var($addr, FILTER_VALIDATE_EMAIL)) { $addresses[] = new Address($addr); } } if (! empty($addresses)) { $email->to(...$addresses); } else { // Validate single to_email before using $singleTo = trim((string) $log->to_email); if ($singleTo === '' || ! filter_var($singleTo, FILTER_VALIDATE_EMAIL)) { throw new \InvalidArgumentException('No valid recipient email found for EmailLog #'.$log->id); } $email->to(new Address($singleTo, (string) ($log->to_name ?? ''))); } // Always BCC the sender mailbox if present and not already in To $senderBcc = null; if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) { // Check duplicates against toList $lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email])); if (! in_array(strtolower($fromAddr), $lowerTo, true)) { $senderBcc = $fromAddr; $email->bcc(new Address($senderBcc)); // Persist BCC for auditing $log->bcc = [$senderBcc]; } } if (! empty($text)) { $email->text($text); } if (! empty($html)) { $email->html($html); } if (! empty($log->reply_to)) { $email->replyTo($log->reply_to); } // Attach files if present on the log $attachments = (array) ($log->attachments ?? []); foreach ($attachments as $att) { try { $disk = $att['disk'] ?? 'public'; $rawPath = $att['path'] ?? null; if (! $rawPath) { continue; } $name = $att['name'] ?? basename($rawPath); $mime = $att['mime'] ?? 'application/octet-stream'; $storage = \Storage::disk($disk); // Build candidate paths (normalize slashes and strip legacy prefixes) $norm = ltrim(str_replace('\\', '/', (string) $rawPath), '/'); $candidates = []; $candidates[] = $norm; if (str_starts_with($norm, 'public/')) { $candidates[] = substr($norm, 7); } if (str_starts_with($norm, 'storage/')) { $candidates[] = substr($norm, 8); } $candidates = array_values(array_unique(array_filter($candidates))); // Diagnostics (guarded by app.debug) if (config('app.debug')) { try { \Log::debug('EmailSender: attachment diagnostics (Symfony mailer)', [ 'log_id' => $log->id, 'disk' => $disk, 'disk_driver' => config('filesystems.disks.'.$disk.'.driver'), 'raw_path' => $rawPath, 'normalized' => $norm, 'candidates' => $candidates, ]); } catch (\Throwable $e) { // ignore logging failures } } $attached = false; // Try local filesystem path first foreach ($candidates as $cand) { try { if (method_exists($storage, 'path')) { $full = $storage->path($cand); if (is_string($full) && is_file($full)) { if (config('app.debug')) { try { \Log::debug('EmailSender: attaching from local path', [ 'log_id' => $log->id, 'disk' => $disk, 'candidate' => $cand, 'full_path' => $full, 'filesize' => @filesize($full) ?: null, 'mime' => $mime, 'name' => $name, ]); } catch (\Throwable $e) { } } $email->attachFromPath($full, $name, $mime); $attached = true; break; } } } catch (\Throwable $e) { } } if (! $attached) { // Fallback for non-local disks (S3, etc.): read bytes and attach foreach ($candidates as $cand) { try { $exists = $storage->exists($cand); if (config('app.debug')) { try { \Log::debug('EmailSender: storage exists check', [ 'log_id' => $log->id, 'disk' => $disk, 'candidate' => $cand, 'exists' => $exists, ]); } catch (\Throwable $e) { } } if ($exists) { $bytes = $storage->get($cand); if (! is_null($bytes) && $bytes !== false) { if (config('app.debug')) { try { \Log::debug('EmailSender: attaching from bytes (Symfony)', [ 'log_id' => $log->id, 'disk' => $disk, 'candidate' => $cand, 'byte_length' => is_string($bytes) ? strlen($bytes) : null, 'mime' => $mime, 'name' => $name, ]); } catch (\Throwable $e) { } } $email->attach($bytes, $name, $mime); $attached = true; break; } } } catch (\Throwable $e) { } } } if (config('app.debug')) { try { \Log::debug('EmailSender: attachment result (Symfony mailer)', [ 'log_id' => $log->id, 'name' => $name, 'attached' => $attached, ]); } catch (\Throwable $e) { } } } catch (\Throwable $e) { // ignore individual attachment failures; continue sending } } $mailer->send($email); // Save log if we modified BCC if (! empty($log->getAttribute('bcc'))) { $log->save(); } $headers = $email->getHeaders(); $messageIdHeader = $headers->get('Message-ID'); $messageId = $messageIdHeader ? $messageIdHeader->getBodyAsString() : null; } else { // Fallback to Laravel mailer if (! empty($html)) { \Mail::html($html, function ($message) use ($log, $subject, $text) { $toList = (array) ($log->to_recipients ?? []); if (! empty($toList)) { $message->to($toList); } else { $singleTo = trim((string) $log->to_email); if ($singleTo === '' || ! filter_var($singleTo, FILTER_VALIDATE_EMAIL)) { throw new \InvalidArgumentException('No valid recipient email found for EmailLog #'.$log->id); } $toName = (string) ($log->to_name ?? ''); if ($toName !== '') { $message->to($singleTo, $toName); } else { $message->to($singleTo); } } // BCC the sender mailbox if resolvable and not already in To $fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? '')); if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) { $lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email])); if (! in_array(strtolower($fromAddr), $lowerTo, true)) { $message->bcc($fromAddr); $log->bcc = [$fromAddr]; } } $message->subject($subject); if (! empty($log->reply_to)) { $message->replyTo($log->reply_to); } if (! empty($text)) { // Provide a plain text alternative when available $message->text($text); } // Attachments via Laravel Mailer (supports storage disks) $attachments = (array) ($log->attachments ?? []); foreach ($attachments as $att) { try { $disk = $att['disk'] ?? 'public'; $rawPath = $att['path'] ?? null; if (! $rawPath) { continue; } $name = $att['name'] ?? basename($rawPath); $mime = $att['mime'] ?? 'application/octet-stream'; $norm = ltrim(str_replace('\\', '/', (string) $rawPath), '/'); $candidates = [$norm]; if (str_starts_with($norm, 'public/')) { $candidates[] = substr($norm, 7); } if (str_starts_with($norm, 'storage/')) { $candidates[] = substr($norm, 8); } if (config('app.debug')) { try { \Log::debug('EmailSender: attachment diagnostics (Laravel html)', [ 'log_id' => $log->id, 'disk' => $disk, 'disk_driver' => config('filesystems.disks.'.$disk.'.driver'), 'raw_path' => $rawPath, 'normalized' => $norm, 'candidates' => array_values(array_unique($candidates)), ]); } catch (\Throwable $e) { } } $attached = false; foreach (array_values(array_unique($candidates)) as $cand) { try { $message->attachFromStorageDisk($disk, $cand, $name, ['mime' => $mime]); if (config('app.debug')) { try { \Log::debug('EmailSender: attached via attachFromStorageDisk (Laravel html)', [ 'log_id' => $log->id, 'disk' => $disk, 'candidate' => $cand, 'mime' => $mime, 'name' => $name, ]); } catch (\Throwable $e) { } } $attached = true; break; } catch (\Throwable $e) { // fall back to bytes } } if (! $attached) { $storage = \Storage::disk($disk); foreach (array_values(array_unique($candidates)) as $cand) { try { $exists = $storage->exists($cand); if (config('app.debug')) { try { \Log::debug('EmailSender: storage exists check (Laravel html)', [ 'log_id' => $log->id, 'disk' => $disk, 'candidate' => $cand, 'exists' => $exists, ]); } catch (\Throwable $e) { } } if ($exists) { $bytes = $storage->get($cand); if (! is_null($bytes) && $bytes !== false) { if (config('app.debug')) { try { \Log::debug('EmailSender: attaching from bytes (Laravel html)', [ 'log_id' => $log->id, 'disk' => $disk, 'candidate' => $cand, 'byte_length' => is_string($bytes) ? strlen($bytes) : null, 'mime' => $mime, 'name' => $name, ]); } catch (\Throwable $e) { } } $message->attachData($bytes, $name, ['mime' => $mime]); break; } } } catch (\Throwable $e) { } } } } catch (\Throwable $e) { } } }); } else { \Mail::raw($text ?: '', function ($message) use ($log, $subject) { $toList = (array) ($log->to_recipients ?? []); if (! empty($toList)) { $message->to($toList); } else { $singleTo = trim((string) $log->to_email); if ($singleTo === '' || ! filter_var($singleTo, FILTER_VALIDATE_EMAIL)) { throw new \InvalidArgumentException('No valid recipient email found for EmailLog #'.$log->id); } $toName = (string) ($log->to_name ?? ''); if ($toName !== '') { $message->to($singleTo, $toName); } else { $message->to($singleTo); } } // BCC the sender mailbox if resolvable and not already in To $fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? '')); if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) { $lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email])); if (! in_array(strtolower($fromAddr), $lowerTo, true)) { $message->bcc($fromAddr); $log->bcc = [$fromAddr]; } } $message->subject($subject); if (! empty($log->reply_to)) { $message->replyTo($log->reply_to); } // Attachments for raw mail $attachments = (array) ($log->attachments ?? []); foreach ($attachments as $att) { try { $disk = $att['disk'] ?? 'public'; $rawPath = $att['path'] ?? null; if (! $rawPath) { continue; } $name = $att['name'] ?? basename($rawPath); $mime = $att['mime'] ?? 'application/octet-stream'; $norm = ltrim(str_replace('\\', '/', (string) $rawPath), '/'); $candidates = [$norm]; if (str_starts_with($norm, 'public/')) { $candidates[] = substr($norm, 7); } if (str_starts_with($norm, 'storage/')) { $candidates[] = substr($norm, 8); } if (config('app.debug')) { try { \Log::debug('EmailSender: attachment diagnostics (Laravel raw)', [ 'log_id' => $log->id, 'disk' => $disk, 'disk_driver' => config('filesystems.disks.'.$disk.'.driver'), 'raw_path' => $rawPath, 'normalized' => $norm, 'candidates' => array_values(array_unique($candidates)), ]); } catch (\Throwable $e) { } } $attached = false; foreach (array_values(array_unique($candidates)) as $cand) { try { $message->attachFromStorageDisk($disk, $cand, $name, ['mime' => $mime]); if (config('app.debug')) { try { \Log::debug('EmailSender: attached via attachFromStorageDisk (Laravel raw)', [ 'log_id' => $log->id, 'disk' => $disk, 'candidate' => $cand, 'mime' => $mime, 'name' => $name, ]); } catch (\Throwable $e) { } } $attached = true; break; } catch (\Throwable $e) { } } if (! $attached) { $storage = \Storage::disk($disk); foreach (array_values(array_unique($candidates)) as $cand) { try { $exists = $storage->exists($cand); if (config('app.debug')) { try { \Log::debug('EmailSender: storage exists check (Laravel raw)', [ 'log_id' => $log->id, 'disk' => $disk, 'candidate' => $cand, 'exists' => $exists, ]); } catch (\Throwable $e) { } } if ($exists) { $bytes = $storage->get($cand); if (! is_null($bytes) && $bytes !== false) { if (config('app.debug')) { try { \Log::debug('EmailSender: attaching from bytes (Laravel raw)', [ 'log_id' => $log->id, 'disk' => $disk, 'candidate' => $cand, 'byte_length' => is_string($bytes) ? strlen($bytes) : null, 'mime' => $mime, 'name' => $name, ]); } catch (\Throwable $e) { } } $message->attachData($bytes, $name, ['mime' => $mime]); break; } } } catch (\Throwable $e) { } } } } catch (\Throwable $e) { } } }); } } return ['message_id' => $messageId]; } }