emailer update fixed so it can now send to multiple recipients
This commit is contained in:
parent
ddfc79ffe8
commit
ed62311ba4
|
|
@ -43,7 +43,6 @@ public function handle(): void
|
||||||
$result = $sender->sendFromLog($log);
|
$result = $sender->sendFromLog($log);
|
||||||
|
|
||||||
$log->status = EmailLogStatus::Sent;
|
$log->status = EmailLogStatus::Sent;
|
||||||
$log->message_id = $result['message_id'] ?? ($log->message_id ?? null);
|
|
||||||
$log->sent_at = now();
|
$log->sent_at = now();
|
||||||
$log->duration_ms = (int) round((microtime(true) - $start) * 1000);
|
$log->duration_ms = (int) round((microtime(true) - $start) * 1000);
|
||||||
$log->save();
|
$log->save();
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,9 @@ class EmailLog extends Model
|
||||||
'template_id',
|
'template_id',
|
||||||
'mail_profile_id',
|
'mail_profile_id',
|
||||||
'user_id',
|
'user_id',
|
||||||
'message_id',
|
|
||||||
'correlation_id',
|
'correlation_id',
|
||||||
'to_email',
|
'to_email',
|
||||||
|
'to_recipients',
|
||||||
'to_name',
|
'to_name',
|
||||||
'cc',
|
'cc',
|
||||||
'bcc',
|
'bcc',
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
use App\Models\EmailLog;
|
use App\Models\EmailLog;
|
||||||
use App\Models\EmailLogStatus;
|
use App\Models\EmailLogStatus;
|
||||||
use App\Models\EmailTemplate;
|
use App\Models\EmailTemplate;
|
||||||
|
use App\Models\MailProfile;
|
||||||
|
|
||||||
class AutoMailDispatcher
|
class AutoMailDispatcher
|
||||||
{
|
{
|
||||||
|
|
@ -95,6 +96,8 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true): array
|
||||||
$log->fill([
|
$log->fill([
|
||||||
'uuid' => (string) \Str::uuid(),
|
'uuid' => (string) \Str::uuid(),
|
||||||
'template_id' => $template->id,
|
'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_email' => (string) ($recipients[0] ?? ''),
|
||||||
'to_recipients' => $recipients,
|
'to_recipients' => $recipients,
|
||||||
'subject' => (string) ($rendered['subject'] ?? $template->subject_template ?? ''),
|
'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,
|
'client_case_id' => $clientCase?->id,
|
||||||
'contract_id' => $contract?->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->save();
|
||||||
|
|
||||||
$log->body()->create([
|
$log->body()->create([
|
||||||
|
|
|
||||||
|
|
@ -94,38 +94,75 @@ public function sendFromLog(EmailLog $log): array
|
||||||
$transport = Transport::fromDsn($dsn);
|
$transport = Transport::fromDsn($dsn);
|
||||||
$mailer = new SymfonyMailer($transport);
|
$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') ?? '')));
|
$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)
|
// Build email with safe Address instances (Symfony Address does not allow null name)
|
||||||
$fromAddress = $fromName !== ''
|
$fromAddress = $fromName !== ''
|
||||||
? new Address($fromAddr ?: $log->to_email, $fromName)
|
? new Address($fromAddr, $fromName)
|
||||||
: new Address($fromAddr ?: $log->to_email);
|
: new Address($fromAddr);
|
||||||
$toAddress = (string) ($log->to_name ?? '') !== ''
|
|
||||||
? new Address($log->to_email, (string) $log->to_name)
|
|
||||||
: new Address($log->to_email);
|
|
||||||
|
|
||||||
$email = (new Email)
|
$email = (new Email)
|
||||||
->from($fromAddress)
|
->from($fromAddress)
|
||||||
->subject($subject);
|
->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 ?? []);
|
$toList = (array) ($log->to_recipients ?? []);
|
||||||
if (! empty($toList)) {
|
$addresses = [];
|
||||||
$addresses = [];
|
foreach ($toList as $addr) {
|
||||||
foreach ($toList as $addr) {
|
$addr = trim((string) $addr);
|
||||||
$addr = trim((string) $addr);
|
if ($addr !== '' && filter_var($addr, FILTER_VALIDATE_EMAIL)) {
|
||||||
if ($addr !== '' && filter_var($addr, FILTER_VALIDATE_EMAIL)) {
|
$addresses[] = new Address($addr);
|
||||||
$addresses[] = new Address($addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (! empty($addresses)) {
|
|
||||||
$email->to(...$addresses);
|
|
||||||
} else {
|
|
||||||
$email->to($toAddress);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (! empty($addresses)) {
|
||||||
|
$email->to(...$addresses);
|
||||||
} else {
|
} 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)) {
|
if (! empty($text)) {
|
||||||
|
|
@ -139,6 +176,10 @@ public function sendFromLog(EmailLog $log): array
|
||||||
}
|
}
|
||||||
|
|
||||||
$mailer->send($email);
|
$mailer->send($email);
|
||||||
|
// Save log if we modified BCC
|
||||||
|
if (! empty($log->getAttribute('bcc'))) {
|
||||||
|
$log->save();
|
||||||
|
}
|
||||||
$headers = $email->getHeaders();
|
$headers = $email->getHeaders();
|
||||||
$messageIdHeader = $headers->get('Message-ID');
|
$messageIdHeader = $headers->get('Message-ID');
|
||||||
$messageId = $messageIdHeader ? $messageIdHeader->getBodyAsString() : null;
|
$messageId = $messageIdHeader ? $messageIdHeader->getBodyAsString() : null;
|
||||||
|
|
@ -150,11 +191,24 @@ public function sendFromLog(EmailLog $log): array
|
||||||
if (! empty($toList)) {
|
if (! empty($toList)) {
|
||||||
$message->to($toList);
|
$message->to($toList);
|
||||||
} else {
|
} 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 ?? '');
|
$toName = (string) ($log->to_name ?? '');
|
||||||
if ($toName !== '') {
|
if ($toName !== '') {
|
||||||
$message->to($log->to_email, $toName);
|
$message->to($singleTo, $toName);
|
||||||
} else {
|
} 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);
|
$message->subject($subject);
|
||||||
|
|
@ -172,11 +226,24 @@ public function sendFromLog(EmailLog $log): array
|
||||||
if (! empty($toList)) {
|
if (! empty($toList)) {
|
||||||
$message->to($toList);
|
$message->to($toList);
|
||||||
} else {
|
} 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 ?? '');
|
$toName = (string) ($log->to_name ?? '');
|
||||||
if ($toName !== '') {
|
if ($toName !== '') {
|
||||||
$message->to($log->to_email, $toName);
|
$message->to($singleTo, $toName);
|
||||||
} else {
|
} 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);
|
$message->subject($subject);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('email_logs', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('email_logs', 'message_id')) {
|
||||||
|
$table->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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -22,10 +22,21 @@ const props = defineProps({
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm p-4 space-y-2">
|
<div class="rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm p-4 space-y-2">
|
||||||
<div class="text-sm"><span class="font-semibold">Status:</span> {{ props.log.status }}</div>
|
<div class="text-sm"><span class="font-semibold">Status:</span> {{ props.log.status }}</div>
|
||||||
<div class="text-sm"><span class="font-semibold">To:</span> {{ props.log.to_email }} {{ props.log.to_name ? '(' + props.log.to_name + ')' : '' }}</div>
|
<div class="text-sm">
|
||||||
|
<span class="font-semibold">To:</span>
|
||||||
|
<template v-if="props.log.to_email">
|
||||||
|
{{ props.log.to_email }} {{ props.log.to_name ? '(' + props.log.to_name + ')' : '' }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span v-if="Array.isArray(props.log.to_recipients) && props.log.to_recipients.length">
|
||||||
|
{{ props.log.to_recipients.join(', ') }}
|
||||||
|
</span>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
<div class="text-sm"><span class="font-semibold">Subject:</span> {{ props.log.subject }}</div>
|
<div class="text-sm"><span class="font-semibold">Subject:</span> {{ props.log.subject }}</div>
|
||||||
<div class="text-sm"><span class="font-semibold">Template:</span> {{ props.log.template?.name || '-' }}</div>
|
<div class="text-sm"><span class="font-semibold">Template:</span> {{ props.log.template?.name || '-' }}</div>
|
||||||
<div class="text-sm"><span class="font-semibold">Message ID:</span> {{ props.log.message_id || '-' }}</div>
|
<!-- Message ID removed per request -->
|
||||||
<div class="text-sm"><span class="font-semibold">Attempts:</span> {{ props.log.attempt }}</div>
|
<div class="text-sm"><span class="font-semibold">Attempts:</span> {{ props.log.attempt }}</div>
|
||||||
<div class="text-sm"><span class="font-semibold">Duration:</span> {{ props.log.duration_ms ? props.log.duration_ms + ' ms' : '-' }}</div>
|
<div class="text-sm"><span class="font-semibold">Duration:</span> {{ props.log.duration_ms ? props.log.duration_ms + ' ms' : '-' }}</div>
|
||||||
<div v-if="props.log.error_message" class="text-sm text-red-700"><span class="font-semibold">Error:</span> {{ props.log.error_message }}</div>
|
<div v-if="props.log.error_message" class="text-sm text-red-700"><span class="font-semibold">Error:</span> {{ props.log.error_message }}</div>
|
||||||
|
|
|
||||||
28
tests/Unit/EmailLogTest.php
Normal file
28
tests/Unit/EmailLogTest.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\EmailLog;
|
||||||
|
use App\Models\EmailLogStatus;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('persists to_recipients array when filling and saving', function () {
|
||||||
|
$log = new EmailLog();
|
||||||
|
$log->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');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user