diff --git a/app/Jobs/SendEmailTemplateJob.php b/app/Jobs/SendEmailTemplateJob.php index f877714..8ef8a12 100644 --- a/app/Jobs/SendEmailTemplateJob.php +++ b/app/Jobs/SendEmailTemplateJob.php @@ -43,7 +43,6 @@ public function handle(): void $result = $sender->sendFromLog($log); $log->status = EmailLogStatus::Sent; - $log->message_id = $result['message_id'] ?? ($log->message_id ?? null); $log->sent_at = now(); $log->duration_ms = (int) round((microtime(true) - $start) * 1000); $log->save(); diff --git a/app/Models/EmailLog.php b/app/Models/EmailLog.php index 1648638..b3ba936 100644 --- a/app/Models/EmailLog.php +++ b/app/Models/EmailLog.php @@ -23,9 +23,9 @@ class EmailLog extends Model 'template_id', 'mail_profile_id', 'user_id', - 'message_id', 'correlation_id', 'to_email', + 'to_recipients', 'to_name', 'cc', 'bcc', diff --git a/app/Services/AutoMailDispatcher.php b/app/Services/AutoMailDispatcher.php index c8856a6..e1ec3c5 100644 --- a/app/Services/AutoMailDispatcher.php +++ b/app/Services/AutoMailDispatcher.php @@ -12,6 +12,7 @@ use App\Models\EmailLog; use App\Models\EmailLogStatus; use App\Models\EmailTemplate; +use App\Models\MailProfile; class AutoMailDispatcher { @@ -95,6 +96,8 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true): array $log->fill([ 'uuid' => (string) \Str::uuid(), 'template_id' => $template->id, + 'mail_profile_id' => optional(MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first())->id, + 'user_id' => auth()->id(), 'to_email' => (string) ($recipients[0] ?? ''), 'to_recipients' => $recipients, 'subject' => (string) ($rendered['subject'] ?? $template->subject_template ?? ''), @@ -107,6 +110,10 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true): array 'client_case_id' => $clientCase?->id, 'contract_id' => $contract?->id, ]); + // If multiple recipients, keep to_email null to avoid implying a single primary recipient + if (count($recipients) > 1) { + $log->to_email = null; + } $log->save(); $log->body()->create([ diff --git a/app/Services/EmailSender.php b/app/Services/EmailSender.php index bd11b46..6ce011a 100644 --- a/app/Services/EmailSender.php +++ b/app/Services/EmailSender.php @@ -94,38 +94,75 @@ public function sendFromLog(EmailLog $log): array $transport = Transport::fromDsn($dsn); $mailer = new SymfonyMailer($transport); - $fromAddr = (string) ($log->from_email ?: ($profile->from_address ?: ($username ?: (config('mail.from.address') ?? '')))); + // 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 ?: $log->to_email, $fromName) - : new Address($fromAddr ?: $log->to_email); - $toAddress = (string) ($log->to_name ?? '') !== '' - ? new Address($log->to_email, (string) $log->to_name) - : new Address($log->to_email); + ? new Address($fromAddr, $fromName) + : new Address($fromAddr); $email = (new Email) ->from($fromAddress) ->subject($subject); - // If multiple recipients are present, address to all; otherwise single to + // Address recipients: prefer multi-recipient list; fall back to validated single address $toList = (array) ($log->to_recipients ?? []); - if (! empty($toList)) { - $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 { - $email->to($toAddress); + $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 { - $email->to($toAddress); + // 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)) { @@ -139,6 +176,10 @@ public function sendFromLog(EmailLog $log): array } $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; @@ -150,11 +191,24 @@ public function sendFromLog(EmailLog $log): array 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($log->to_email, $toName); + $message->to($singleTo, $toName); } else { - $message->to($log->to_email); + $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); @@ -172,11 +226,24 @@ public function sendFromLog(EmailLog $log): array 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($log->to_email, $toName); + $message->to($singleTo, $toName); } else { - $message->to($log->to_email); + $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); diff --git a/database/migrations/2025_10_15_000001_update_email_logs_drop_message_id_make_to_email_nullable.php b/database/migrations/2025_10_15_000001_update_email_logs_drop_message_id_make_to_email_nullable.php new file mode 100644 index 0000000..2e7d1cb --- /dev/null +++ b/database/migrations/2025_10_15_000001_update_email_logs_drop_message_id_make_to_email_nullable.php @@ -0,0 +1,32 @@ +dropUnique(['message_id']); + $table->dropColumn('message_id'); + } + // Make to_email nullable to allow blank when multiple recipients + $table->string('to_email')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('email_logs', function (Blueprint $table) { + // Recreate message_id as nullable unique if needed + if (! Schema::hasColumn('email_logs', 'message_id')) { + $table->string('message_id')->nullable()->unique(); + } + // Revert to_email to not nullable + $table->string('to_email')->nullable(false)->change(); + }); + } +}; diff --git a/resources/js/Pages/Admin/EmailLogs/Show.vue b/resources/js/Pages/Admin/EmailLogs/Show.vue index b25fa5d..ca27a30 100644 --- a/resources/js/Pages/Admin/EmailLogs/Show.vue +++ b/resources/js/Pages/Admin/EmailLogs/Show.vue @@ -22,10 +22,21 @@ const props = defineProps({
Status: {{ props.log.status }}
-
To: {{ props.log.to_email }} {{ props.log.to_name ? '(' + props.log.to_name + ')' : '' }}
+
+ To: + + +
Subject: {{ props.log.subject }}
Template: {{ props.log.template?.name || '-' }}
-
Message ID: {{ props.log.message_id || '-' }}
+
Attempts: {{ props.log.attempt }}
Duration: {{ props.log.duration_ms ? props.log.duration_ms + ' ms' : '-' }}
Error: {{ props.log.error_message }}
diff --git a/tests/Unit/EmailLogTest.php b/tests/Unit/EmailLogTest.php new file mode 100644 index 0000000..6817ba4 --- /dev/null +++ b/tests/Unit/EmailLogTest.php @@ -0,0 +1,28 @@ +fill([ + 'uuid' => (string) Str::uuid(), + 'to_email' => 'first@example.com', + 'to_recipients' => ['first@example.com', 'second@example.com'], + 'subject' => 'Test Subject', + 'status' => EmailLogStatus::Queued, + 'queued_at' => now(), + ]); + + $log->save(); + + $fresh = EmailLog::query()->findOrFail($log->id); + expect($fresh->to_recipients) + ->toBeArray() + ->and($fresh->to_recipients) + ->toContain('first@example.com', 'second@example.com'); +});