585 lines
30 KiB
PHP
585 lines
30 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\EmailLog;
|
|
use App\Models\MailProfile;
|
|
use Symfony\Component\Mailer\Mailer as SymfonyMailer;
|
|
use Symfony\Component\Mailer\Transport;
|
|
use Symfony\Component\Mime\Address;
|
|
use Symfony\Component\Mime\Email;
|
|
use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles;
|
|
|
|
class EmailSender
|
|
{
|
|
public function __construct(public EmailTemplateRenderer $renderer) {}
|
|
|
|
/**
|
|
* Build and send the message described by the EmailLog. Returns ['message_id' => 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('#<img([^>]+)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 '<img'.$before.'src="'.$abs.'"'.$after.'>';
|
|
}, $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];
|
|
}
|
|
}
|