onQueue('email'); } public function handle(EmailTemplateRenderer $renderer, EmailSender $sender): void { /** @var PackageItem|null $item */ $item = PackageItem::query()->find($this->packageItemId); if (! $item) { return; } /** @var Package $package */ $package = $item->package; if (! $package || $package->status === Package::STATUS_CANCELED) { return; } if (in_array($item->status, ['sent', 'failed', 'canceled', 'skipped'], true)) { return; } if ($item->status === 'queued') { $item->status = 'processing'; $item->save(); $package->increment('processing_count'); } $payload = (array) $item->payload_json; $target = (array) $item->target_json; $to = $target['email'] ?? null; if (! is_string($to) || ! filter_var($to, FILTER_VALIDATE_EMAIL)) { $item->status = 'failed'; $item->last_error = 'Missing or invalid recipient email.'; $item->save(); $this->updatePackageCounters($item, $package); return; } $templateId = $payload['template_id'] ?? null; $mailProfileId = $payload['mail_profile_id'] ?? null; $variables = (array) ($payload['variables'] ?? []); $subjectOverride = isset($payload['subject']) ? trim((string) $payload['subject']) : null; if ($subjectOverride === '') { $subjectOverride = null; } $bodyText = isset($payload['body_text']) ? (string) $payload['body_text'] : ''; // Enrich variables with contract/account context when available $contract = null; if (! empty($target['contract_id'])) { $contract = Contract::query()->with(['clientCase.person', 'account.type'])->find($target['contract_id']); if ($contract) { $variables['contract'] = [ 'id' => $contract->id, 'uuid' => $contract->uuid, 'reference' => $contract->reference, 'start_date' => (string) ($contract->start_date ?? ''), 'end_date' => (string) ($contract->end_date ?? ''), ]; if (is_array($contract->meta) && ! empty($contract->meta)) { $variables['contract']['meta'] = $this->flattenMeta($contract->meta); } if ($contract->account) { $initialRaw = (string) $contract->account->initial_amount; $balanceRaw = (string) $contract->account->balance_amount; $variables['account'] = [ 'id' => $contract->account->id, 'reference' => $contract->account->reference, 'initial_amount' => $this->formatAmountEu($initialRaw), 'balance_amount' => $this->formatAmountEu($balanceRaw), 'initial_amount_raw' => $initialRaw, 'balance_amount_raw' => $balanceRaw, 'type' => $contract->account->type?->name, ]; } if ($contract->clientCase?->person) { $person = $contract->clientCase->person; $variables['person'] = [ 'full_name' => $person->full_name, 'first_name' => $person->first_name, 'last_name' => $person->last_name, ]; } } } /** @var EmailTemplate|null $template */ $template = $templateId ? EmailTemplate::with(['action', 'decision'])->find((int) $templateId) : null; /** @var MailProfile|null $mailProfile */ $mailProfile = $mailProfileId ? MailProfile::find((int) $mailProfileId) : MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first(); try { if (! $template && ! $subjectOverride) { throw new \RuntimeException('No email template or subject provided.'); } $rendered = $template ? $renderer->render([ 'subject' => $subjectOverride ?? (string) $template->subject_template, 'html' => (string) $template->html_template, 'text' => (string) $template->text_template, ], array_filter([ 'contract' => $contract, 'person' => $contract?->clientCase?->person, 'client' => $contract?->clientCase?->client, 'client_case' => $contract?->clientCase, 'mail_profile' => $mailProfile, 'extra' => $variables, 'body_text' => $bodyText !== '' ? $bodyText : null, ])) : [ 'subject' => $subjectOverride ?? '', 'html' => null, 'text' => null, ]; $log = new EmailLog; $log->fill([ 'uuid' => (string) Str::uuid(), 'template_id' => $template?->id, 'mail_profile_id' => $mailProfile?->id, 'to_email' => $to, 'to_recipients' => [$to], 'subject' => $rendered['subject'], 'body_html_hash' => isset($rendered['html']) ? hash('sha256', (string) $rendered['html']) : null, 'body_text_preview' => isset($rendered['text']) ? mb_strimwidth((string) $rendered['text'], 0, 4096) : null, 'embed_mode' => 'base64', 'status' => EmailLogStatus::Queued, 'queued_at' => now(), 'contract_id' => $contract?->id, 'client_id' => $contract?->clientCase?->client?->id, 'client_case_id' => $contract?->clientCase?->id, 'extra_context' => ['package_id' => $item->package_id, 'package_item_id' => $item->id], ]); $log->save(); $log->body()->create([ 'body_html' => (string) ($rendered['html'] ?? ''), 'body_text' => (string) ($rendered['text'] ?? ''), 'inline_css' => true, ]); // Send directly (synchronous within job context) $start = microtime(true); $log->status = EmailLogStatus::Sending; $log->started_at = now(); $log->attempt = 1; $log->save(); $sender->sendFromLog($log); $log->status = EmailLogStatus::Sent; $log->sent_at = now(); $log->duration_ms = (int) round((microtime(true) - $start) * 1000); $log->save(); $item->status = 'sent'; $item->result_json = ['email_log_id' => $log->id, 'subject' => $rendered['subject']]; $item->last_error = null; $item->save(); // Clear failed flag on successful delivery Email::query()->where('value', $to)->where('failed', true)->update(['failed' => false]); // Create activity if the template has action/decision configured if ($template && ($template->action_id || $template->decision_id) && $contract && $contract->client_case_id) { $activity = \App\Models\Activity::create(array_filter([ 'client_case_id' => $contract->client_case_id, 'contract_id' => $contract->id, 'action_id' => $template->action_id, 'decision_id' => $template->decision_id, 'note' => 'Poslano: '.$to.', Uspešno'.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''), ])); $activity->emailLogs()->attach($log->id); } } catch (\Throwable $e) { $item->status = 'failed'; $item->last_error = $e->getMessage(); $item->save(); // Create activity for failed send if the template has action/decision configured if ($template && ($template->action_id || $template->decision_id) && isset($contract) && $contract && $contract->client_case_id) { $shortError = mb_strimwidth($e->getMessage(), 0, 120, '…'); $activity = \App\Models\Activity::create(array_filter([ 'client_case_id' => $contract->client_case_id, 'contract_id' => $contract->id, 'action_id' => $template->action_id, 'decision_id' => $template->decision_id, 'note' => 'Poslano: '.$to.', Napaka pri pošiljanju: '.$shortError.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''), ])); if (isset($log) && $log->exists) { $activity->emailLogs()->attach($log->id); } } // Mark the email address as failed in the DB. if (isset($to)) { Email::query() ->where('value', $to) ->update(['failed' => true]); } // Permanent SMTP rejection (550 user unknown, 551 not local, 553 invalid address) // means the address definitively does not exist — also mark it invalid. if ($e instanceof \Symfony\Component\Mailer\Exception\TransportExceptionInterface && preg_match('/\b55[013]\b/', $e->getMessage()) && isset($to)) { Email::query() ->where('value', $to) ->update(['valid' => false]); } } $this->updatePackageCounters($item, $package); } private function updatePackageCounters(PackageItem $item, Package $package): void { if ($item->status === 'sent') { $package->increment('sent_count'); } else { $package->increment('failed_count'); } $package->decrement('processing_count'); $package->refresh(); $done = $package->sent_count + $package->failed_count; if ($done >= $package->total_items) { $package->status = $package->failed_count > 0 ? Package::STATUS_FAILED : Package::STATUS_COMPLETED; $package->finished_at = now(); $package->save(); } } private function flattenMeta(array $meta, string $prefix = ''): array { $result = []; foreach ($meta as $key => $value) { $newKey = $prefix === '' ? $key : "{$prefix}.{$key}"; if (is_array($value)) { if (isset($value['value'])) { $result[$newKey] = $value['value']; } else { $nested = $this->flattenMeta($value, $newKey); $result = array_merge($result, $nested); } } else { $result[$newKey] = $value; } } return $result; } private function formatAmountEu(string $raw): string { $numeric = preg_replace('/[^0-9.]/', '', $raw); $float = (float) $numeric; return number_format($float, 2, ',', '.'); } }