authorize('viewAny', EmailTemplate::class); return Inertia::render('Admin/EmailTemplates/Index', [ 'templates' => EmailTemplate::orderBy('name')->get(), ]); } public function create(): Response { $this->authorize('create', EmailTemplate::class); return Inertia::render('Admin/EmailTemplates/Edit', [ 'template' => null, ]); } public function store(StoreEmailTemplateRequest $request) { $data = $request->validated(); $tpl = EmailTemplate::create($data); // Move any tmp images referenced in HTML into permanent storage and attach as documents $this->adoptTmpImages($tpl); return redirect()->route('admin.email-templates.edit', $tpl)->with('success', 'Template created'); } public function edit(EmailTemplate $emailTemplate): Response { $this->authorize('update', $emailTemplate); $emailTemplate->load(['documents' => function ($q) { $q->select(['id', 'documentable_id', 'documentable_type', 'name', 'path', 'size', 'created_at']); }]); return Inertia::render('Admin/EmailTemplates/Edit', [ 'template' => $emailTemplate, ]); } public function update(UpdateEmailTemplateRequest $request, EmailTemplate $emailTemplate) { $data = $request->validated(); $emailTemplate->update($data); $this->adoptTmpImages($emailTemplate); return back()->with('success', 'Template updated'); } public function destroy(EmailTemplate $emailTemplate) { $this->authorize('delete', $emailTemplate); $emailTemplate->delete(); return redirect()->route('admin.email-templates.index')->with('success', 'Template deleted'); } public function preview(Request $request, EmailTemplate $emailTemplate) { $this->authorize('view', $emailTemplate); $renderer = app(EmailTemplateRenderer::class); $subject = (string) $request->input('subject', $emailTemplate->subject_template); $html = (string) $request->input('html', $emailTemplate->html_template); $text = (string) $request->input('text', $emailTemplate->text_template); // Resolve sample entities by ID if given $ctx = []; // Prefer contract -> case -> client for deriving person if ($id = $request->integer('contract_id')) { $contract = Contract::query()->with(['clientCase.client.person'])->find($id); if ($contract) { $ctx['contract'] = $contract; if ($contract->clientCase) { $ctx['client_case'] = $contract->clientCase; if ($contract->clientCase->client) { $ctx['client'] = $contract->clientCase->client; $ctx['person'] = optional($contract->clientCase->client)->person; } } } } if (! isset($ctx['client_case']) && ($id = $request->integer('case_id'))) { $case = ClientCase::query()->with(['client.person'])->find($id); if ($case) { $ctx['client_case'] = $case; if ($case->client) { $ctx['client'] = $case->client; $ctx['person'] = optional($case->client)->person; } } } if (! isset($ctx['client']) && ($id = $request->integer('client_id'))) { $client = Client::query()->with(['person'])->find($id); if ($client) { $ctx['client'] = $client; $ctx['person'] = optional($client)->person; } } $ctx['extra'] = (array) $request->input('extra', []); $result = $renderer->render([ 'subject' => $subject, 'html' => $html, 'text' => $text, ], $ctx); // Repair and attach images, then embed as requested if (! empty($result['html'])) { $result['html'] = $this->repairImgWithoutSrc($result['html']); $result['html'] = $this->attachSrcFromTemplateDocuments($emailTemplate, $result['html']); $embed = (string) $request->input('embed', 'base64'); // hosted | base64 if ($embed === 'base64') { try { $imageInliner = app(\App\Services\EmailImageInliner::class); $result['html'] = $imageInliner->inline($result['html']); } catch (\Throwable $e) { // ignore preview image inlining errors } } else { $result['html'] = $this->absolutizeStorageUrls($request, $result['html']); } try { $inliner = new CssToInlineStyles; $result['html'] = $inliner->convert($result['html']); } catch (\Throwable $e) { // ignore preview inlining errors } } return response()->json($result); } public function sendTest(Request $request, EmailTemplate $emailTemplate) { $this->authorize('send', $emailTemplate); $renderer = app(EmailTemplateRenderer::class); $subject = (string) ($request->input('subject') ?? $emailTemplate->subject_template); $html = (string) ($request->input('html') ?? $emailTemplate->html_template); $text = (string) ($request->input('text') ?? $emailTemplate->text_template); // Adopt tmp images (tmp/email-images) so test email can display images; also persist $html = $this->adoptTmpImagesInHtml($emailTemplate, $html, true); $ctx = []; if ($id = $request->integer('contract_id')) { $contract = Contract::query()->with(['clientCase.client.person'])->find($id); if ($contract) { $ctx['contract'] = $contract; if ($contract->clientCase) { $ctx['client_case'] = $contract->clientCase; if ($contract->clientCase->client) { $ctx['client'] = $contract->clientCase->client; $ctx['person'] = optional($contract->clientCase->client)->person; } } } } if (! isset($ctx['client_case']) && ($id = $request->integer('case_id'))) { $case = ClientCase::query()->with(['client.person'])->find($id); if ($case) { $ctx['client_case'] = $case; if ($case->client) { $ctx['client'] = $case->client; $ctx['person'] = optional($case->client)->person; } } } if (! isset($ctx['client']) && ($id = $request->integer('client_id'))) { $client = Client::query()->with(['person'])->find($id); if ($client) { $ctx['client'] = $client; $ctx['person'] = optional($client)->person; } } $ctx['extra'] = (array) $request->input('extra', []); $rendered = $renderer->render([ 'subject' => $subject, 'html' => $html, 'text' => $text, ], $ctx); $to = (string) $request->input('to'); if ($to === '' || ! filter_var($to, FILTER_VALIDATE_EMAIL)) { return back()->with('error', 'Invalid target email'); } // First repair images missing src if they are followed by a URL (editor artifact) if (! empty($rendered['html'])) { $rendered['html'] = $this->repairImgWithoutSrc($rendered['html']); $rendered['html'] = $this->attachSrcFromTemplateDocuments($emailTemplate, $rendered['html']); } // Embed images as requested (default hosted for Gmail compatibility) $htmlForSend = $rendered['html'] ?? ''; $embed = (string) $request->input('embed', 'base64'); // Prefer the active Mail Profile for sending test emails $subject = $rendered['subject'] ?? ''; $profile = MailProfile::query() ->where('active', true) ->orderBy('priority') ->orderBy('id') ->first(); try { 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); $fromAddr = $profile->from_address ?: ($username ?: (config('mail.from.address') ?? '')); $fromName = $profile->from_name ?: (config('mail.from.name') ?? config('app.name')); $email = (new Email) ->from(new Address($fromAddr ?: $to, $fromName ?: null)) ->to($to) ->subject($subject); if (! empty($rendered['text'])) { $email->text($rendered['text']); } if (! empty($htmlForSend)) { if ($embed === 'base64') { try { $imageInliner = app(\App\Services\EmailImageInliner::class); $htmlForSend = $imageInliner->inline($htmlForSend); } catch (\Throwable $e) { } } else { $htmlForSend = $this->absolutizeStorageUrls($request, $htmlForSend); } try { $inliner = new CssToInlineStyles; $htmlForSend = $inliner->convert($htmlForSend); } catch (\Throwable $e) { } $email->html($htmlForSend); } $mailer->send($email); } else { // Fallback to default Laravel mailer if (! empty($htmlForSend)) { if ($embed === 'base64') { try { $imageInliner = app(\App\Services\EmailImageInliner::class); $htmlForSend = $imageInliner->inline($htmlForSend); } catch (\Throwable $e) { } } else { $htmlForSend = $this->absolutizeStorageUrls($request, $htmlForSend); } try { $inliner = new CssToInlineStyles; $htmlForSend = $inliner->convert($htmlForSend); } catch (\Throwable $e) { } Mail::html($htmlForSend, function ($message) use ($to, $subject, $rendered) { $message->to($to)->subject($subject); if (! empty($rendered['text'])) { $message->text('mail::raw', ['slot' => $rendered['text']]); } }); } else { Mail::raw($rendered['text'] ?? '', function ($message) use ($to, $subject) { $message->to($to)->subject($subject); }); } } return back()->with('success', 'Test email sent to '.$to); } catch (\Throwable $e) { return back()->with('error', 'Failed to send test email: '.$e->getMessage()); } } /** * Render the final HTML exactly as it will be sent (repair , attach from docs, * inline images to base64, inline CSS). Does not persist any changes or send email. */ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate) { $this->authorize('view', $emailTemplate); $renderer = app(EmailTemplateRenderer::class); $subject = (string) ($request->input('subject') ?? $emailTemplate->subject_template); $html = (string) ($request->input('html') ?? $emailTemplate->html_template); $text = (string) ($request->input('text') ?? $emailTemplate->text_template); // Do not persist tmp images, but allow previewing with them present $html = $this->adoptTmpImagesInHtml($emailTemplate, $html, false); // Context resolution (same as sendTest) $ctx = []; if ($id = $request->integer('contract_id')) { $contract = Contract::query()->with(['clientCase.client.person'])->find($id); if ($contract) { $ctx['contract'] = $contract; if ($contract->clientCase) { $ctx['client_case'] = $contract->clientCase; if ($contract->clientCase->client) { $ctx['client'] = $contract->clientCase->client; $ctx['person'] = optional($contract->clientCase->client)->person; } } } } if (! isset($ctx['client_case']) && ($id = $request->integer('case_id'))) { $case = ClientCase::query()->with(['client.person'])->find($id); if ($case) { $ctx['client_case'] = $case; if ($case->client) { $ctx['client'] = $case->client; $ctx['person'] = optional($case->client)->person; } } } if (! isset($ctx['client']) && ($id = $request->integer('client_id'))) { $client = Client::query()->with(['person'])->find($id); if ($client) { $ctx['client'] = $client; $ctx['person'] = optional($client)->person; } } $ctx['extra'] = (array) $request->input('extra', []); $rendered = $renderer->render([ 'subject' => $subject, 'html' => $html, 'text' => $text, ], $ctx); $attachments = []; if (! empty($rendered['html'])) { $rendered['html'] = $this->repairImgWithoutSrc($rendered['html']); $rendered['html'] = $this->attachSrcFromTemplateDocuments($emailTemplate, $rendered['html']); $embed = (string) $request->input('embed', 'base64'); if ($embed === 'base64') { try { $imageInliner = app(\App\Services\EmailImageInliner::class); $rendered['html'] = $imageInliner->inline($rendered['html']); } catch (\Throwable $e) { } } else { $rendered['html'] = $this->absolutizeStorageUrls($request, $rendered['html']); } try { $inliner = new CssToInlineStyles; $rendered['html'] = $inliner->convert($rendered['html']); } catch (\Throwable $e) { } } return response()->json([ 'subject' => $rendered['subject'] ?? $subject, 'html' => $rendered['html'] ?? '', 'text' => $rendered['text'] ?? ($text ?? ''), 'attachments' => $attachments, ]); } /** * Convert any (or absolute URLs whose path is /storage/...) to * absolute URLs using the current request scheme+host, so email clients like Gmail can fetch * them through their proxy reliably. */ protected function absolutizeStorageUrls(Request $request, string $html): string { if ($html === '' || stripos($html, 'getSchemeAndHttpHost(); return 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); } /** * Fix patterns where an tag lacks a src attribute but is immediately followed by a URL. * Example to fix: * Logo\nhttps://domain.tld/storage/email-images/foo.png * becomes: * Logo * The trailing URL text is removed. */ protected function repairImgWithoutSrc(string $html): string { if ($html === '' || stripos($html, ' when not present and keep the in-between content $setSrc = function (array $m): string { $attrs = $m[1] ?? ''; $between = $m[2] ?? ''; $url = $m[3] ?? ''; if (preg_match('#\bsrc\s*=#i', $attrs)) { return $m[0]; } return ''.$between; }; // Up to 700 chars of any content (non-greedy) between tag and URL $gap = '(.{0,700}?)'; $urlAbs = '(https?://[^\s<>"\']+/storage/[^\s<>"\']+)'; $urlRel = '(/storage/[^\s<>"\']+)'; // Case 1: Plain text URL after $html = preg_replace_callback('#]*)>'.$gap.$urlAbs.'#is', $setSrc, $html); $html = preg_replace_callback('#]*)>'.$gap.$urlRel.'#is', $setSrc, $html); // Case 2: Linked URL after (keep the anchor text, consume the URL into src) $setSrcAnchor = function (array $m): string { $attrs = $m[1] ?? ''; $between = $m[2] ?? ''; $url = $m[3] ?? ''; $anchor = $m[4] ?? ''; if (preg_match('#\bsrc\s*=#i', $attrs)) { return $m[0]; } // Keep the anchor but its href stays as-is; we only set img src return ''.$between.$anchor; }; $html = preg_replace_callback('#]*)>'.$gap.$urlAbs.'(\s*]+href=["\'][^"\']+["\'][^>]*>.*?)#is', $setSrcAnchor, $html); $html = preg_replace_callback('#]*)>'.$gap.$urlRel.'(\s*]+href=["\'][^"\']+["\'][^>]*>.*?)#is', $setSrcAnchor, $html); // Fallback: if a single image is missing src and there is a single /storage URL anywhere, attach it if (preg_match_all('#]*\bsrc\s*=)[^>]*>#i', $html, $missingImgs) === 1) { if (count($missingImgs[0]) === 1) { if (preg_match_all('#(?:https?://[^\s<>"\']+)?/storage/[^\s<>"\']+#i', $html, $urls) === 1 && count($urls[0]) === 1) { $onlyUrl = $urls[0][0]; $html = preg_replace('#]*\bsrc\s*=)[^>]*)>#i', '', $html, 1); } } } return $html; } /** * As a conservative fallback, populate missing src attributes using this template's * attached image Documents. We try to match by the alt attribute first (e.g., alt="Logo" * will match a document named "logo.*"); if there is only one image document, we will use it. */ protected function attachSrcFromTemplateDocuments(EmailTemplate $tpl, string $html): string { if ($html === '' || stripos($html, 'getRelationValue('documents'); if ($docs === null) { $docs = $tpl->documents()->get(['id', 'name', 'path', 'file_name', 'original_name', 'mime_type']); } $imageDocs = collect($docs ?: [])->filter(function ($d) { $mime = strtolower((string) ($d->mime_type ?? '')); return $mime === '' || str_starts_with($mime, 'image/'); })->values(); if ($imageDocs->isEmpty()) { return $html; } // Build lookups by basename without extension $byStem = []; foreach ($imageDocs as $d) { $base = pathinfo($d->file_name ?: ($d->name ?: ($d->original_name ?: basename((string) $d->path))), PATHINFO_FILENAME); if ($base) { $byStem[strtolower($base)] = $d; } } $callback = function (array $m) use (&$byStem, $imageDocs) { $attrs = $m[1] ?? ''; if (preg_match('#\bsrc\s*=#i', $attrs)) { return $m[0]; } $alt = null; if (preg_match('#\balt\s*=\s*(?:"([^"]*)"|\'([^\']*)\')#i', $attrs, $am)) { $alt = trim(html_entity_decode($am[1] !== '' ? $am[1] : ($am[2] ?? ''), ENT_QUOTES | ENT_HTML5)); } $chosen = null; if ($alt) { $key = strtolower(preg_replace('#[^a-z0-9]+#i', '', $alt)); // try exact stem if (isset($byStem[strtolower($alt)])) { $chosen = $byStem[strtolower($alt)]; } if (! $chosen) { // try relaxed: any stem containing the alt foreach ($byStem as $stem => $doc) { $relaxedStem = preg_replace('#[^a-z0-9]+#i', '', (string) $stem); if ($relaxedStem !== '' && str_contains($relaxedStem, $key)) { $chosen = $doc; break; } } } } if (! $chosen && method_exists($imageDocs, 'count') && $imageDocs->count() === 1) { $chosen = $imageDocs->first(); } if (! $chosen) { return $m[0]; } $url = '/storage/'.ltrim((string) $chosen->path, '/'); return ''; }; $html = preg_replace_callback('#]*)>#i', $callback, $html); return $html; } /** * Upload an image for use in email templates. Stores to a temporary folder first and returns a public URL. */ public function uploadImage(Request $request) { $this->authorize('create', EmailTemplate::class); $validated = $request->validate([ 'file' => ['required', 'image', 'max:5120'], // 5MB ]); /** @var \Illuminate\Http\UploadedFile $file */ $file = $validated['file']; // store into tmp first; move on save $path = $file->store('tmp/email-images', 'public'); // Return a relative URL to avoid mismatched host/ports in dev $url = '/storage/'.$path; return response()->json([ 'url' => $url, 'path' => $path, 'tmp' => true, ]); } /** * Replace an image referenced by the template, updating the existing Document row if found * (and deleting the old file), or creating a new Document if none exists. Returns the new URL. */ public function replaceImage(Request $request, EmailTemplate $emailTemplate) { $this->authorize('update', $emailTemplate); $validated = $request->validate([ 'file' => ['required', 'image', 'max:5120'], 'current_src' => ['nullable', 'string'], ]); /** @var \Illuminate\Http\UploadedFile $file */ $file = $validated['file']; $currentSrc = (string) ($validated['current_src'] ?? ''); // Normalize current src to a public disk path when possible $currentPath = null; if ($currentSrc !== '') { $parsed = parse_url($currentSrc); $path = $parsed['path'] ?? $currentSrc; // Accept /storage/... or raw path; strip leading storage/ if (preg_match('#/storage/(.+)#i', $path, $m)) { $path = $m[1]; } $path = ltrim(preg_replace('#^storage/#', '', $path), '/'); if ($path !== '') { $currentPath = $path; } } // Find existing document for this template matching the path $doc = null; if ($currentPath) { $doc = $emailTemplate->documents()->where('path', $currentPath)->first(); } // Store the new file $ext = $file->getClientOriginalExtension(); $nameBase = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME) ?: 'image'; $dest = 'email-images/'.$nameBase.'-'.Str::uuid().($ext ? ('.'.$ext) : ''); Storage::disk('public')->put($dest, File::get($file->getRealPath())); // Delete old file if we will update an existing document if ($doc && $doc->path && Storage::disk('public')->exists($doc->path)) { try { Storage::disk('public')->delete($doc->path); } catch (\Throwable $e) { // ignore } } $full = storage_path('app/public/'.$dest); try { $mime = File::exists($full) ? File::mimeType($full) : null; } catch (\Throwable $e) { $mime = null; } try { $size = Storage::disk('public')->size($dest); } catch (\Throwable $e) { $size = null; } if ($doc) { $doc->forceFill([ 'name' => basename($dest), 'path' => $dest, 'file_name' => basename($dest), 'original_name' => $file->getClientOriginalName(), 'extension' => $ext ?: null, 'mime_type' => $mime, 'size' => $size, ])->save(); } else { $doc = $emailTemplate->documents()->create([ 'name' => basename($dest), 'description' => null, 'user_id' => optional(auth()->user())->id, 'disk' => 'public', 'path' => $dest, 'file_name' => basename($dest), 'original_name' => $file->getClientOriginalName(), 'extension' => $ext ?: null, 'mime_type' => $mime, 'size' => $size, 'is_public' => true, ]); } return response()->json([ 'url' => '/storage/'.$dest, 'path' => $dest, 'document_id' => $doc->id, 'replaced' => (bool) $currentPath, ]); } /** * Scan HTML for images stored in /storage/tmp/email-images and move them into a permanent * location under /storage/email-images, create Document records and update the HTML. */ protected function adoptTmpImages(EmailTemplate $tpl): void { $html = (string) ($tpl->html_template ?? ''); if ($html === '' || stripos($html, 'tmp/email-images/') === false) { return; } // Match any tmp paths inside src attributes, accepting absolute or relative URLs $paths = []; $matches = []; if (preg_match_all('#/storage/tmp/email-images/[^"\']+#i', $html, $matches)) { $paths = array_merge($paths, $matches[0]); } if (preg_match_all('#tmp/email-images/[^"\']+#i', $html, $matches)) { $paths = array_merge($paths, $matches[0]); } $paths = array_values(array_unique($paths)); if (empty($paths)) { return; } foreach ($paths as $tmpRel) { // Normalize path (strip any leading storage/) // Normalize to disk-relative path $tmpRel = ltrim(preg_replace('#^/?storage/#i', '', $tmpRel), '/'); if (! Storage::disk('public')->exists($tmpRel)) { continue; } $ext = pathinfo($tmpRel, PATHINFO_EXTENSION); $base = pathinfo($tmpRel, PATHINFO_FILENAME); $candidate = 'email-images/'.$base.'-'.Str::uuid().($ext ? ('.'.$ext) : ''); // Ensure dest doesn't exist while (Storage::disk('public')->exists($candidate)) { $candidate = 'email-images/'.$base.'-'.Str::uuid().($ext ? ('.'.$ext) : ''); } // Move the file Storage::disk('public')->move($tmpRel, $candidate); // Create Document record try { $full = storage_path('app/public/'.$candidate); $mime = File::exists($full) ? File::mimeType($full) : null; } catch (\Throwable $e) { $mime = null; } try { $size = Storage::disk('public')->size($candidate); } catch (\Throwable $e) { $size = null; } $tpl->documents()->create([ 'name' => basename($candidate), 'description' => null, 'user_id' => optional(auth()->user())->id, 'disk' => 'public', 'path' => $candidate, 'file_name' => basename($candidate), 'original_name' => basename($candidate), 'extension' => $ext ?: null, 'mime_type' => $mime, 'size' => $size, 'is_public' => true, ]); // Update HTML to reference the new permanent path (use relative /storage URL) $to = '/storage/'.$candidate; $from = ['/storage/'.$tmpRel, $tmpRel]; $html = str_replace($from, $to, $html); // Also replace absolute URL variants like https://domain/storage/ $pattern = '#https?://[^"\']+/storage/'.preg_quote($tmpRel, '#').'#i'; $html = preg_replace($pattern, $to, $html); } if ($html !== (string) ($tpl->html_template ?? '')) { $tpl->forceFill(['html_template' => $html])->save(); } } /** * Move any tmp images present in the provided HTML into permanent storage, attach documents, * and return the updated HTML. Optionally persist the template's HTML. */ protected function adoptTmpImagesInHtml(EmailTemplate $tpl, string $html, bool $persistTemplate = false): string { if ($html === '' || stripos($html, 'tmp/email-images/') === false) { return $html; } $paths = []; $matches = []; if (preg_match_all('#/storage/tmp/email-images/[^"\']+#i', $html, $matches)) { $paths = array_merge($paths, $matches[0]); } if (preg_match_all('#tmp/email-images/[^"\']+#i', $html, $matches)) { $paths = array_merge($paths, $matches[0]); } $paths = array_values(array_unique($paths)); if (empty($paths)) { return $html; } foreach ($paths as $tmpRel) { $tmpRel = ltrim(preg_replace('#^/?storage/#i', '', $tmpRel), '/'); if (! Storage::disk('public')->exists($tmpRel)) { continue; } $ext = pathinfo($tmpRel, PATHINFO_EXTENSION); $base = pathinfo($tmpRel, PATHINFO_FILENAME); $candidate = 'email-images/'.$base.'-'.Str::uuid().($ext ? ('.'.$ext) : ''); while (Storage::disk('public')->exists($candidate)) { $candidate = 'email-images/'.$base.'-'.Str::uuid().($ext ? ('.'.$ext) : ''); } Storage::disk('public')->move($tmpRel, $candidate); try { $mime = File::exists(storage_path('app/public/'.$candidate)) ? File::mimeType(storage_path('app/public/'.$candidate)) : null; } catch (\Throwable $e) { $mime = null; } try { $size = Storage::disk('public')->size($candidate); } catch (\Throwable $e) { $size = null; } $tpl->documents()->create([ 'name' => basename($candidate), 'description' => null, 'user_id' => optional(auth()->user())->id, 'disk' => 'public', 'path' => $candidate, 'file_name' => basename($candidate), 'original_name' => basename($candidate), 'extension' => $ext ?: null, 'mime_type' => $mime, 'size' => $size, 'is_public' => true, ]); $to = '/storage/'.$candidate; $from = ['/storage/'.$tmpRel, $tmpRel]; $html = str_replace($from, $to, $html); $pattern = '#https?://[^"\']+/storage/'.preg_quote($tmpRel, '#').'#i'; $html = preg_replace($pattern, $to, $html); } if ($persistTemplate && $tpl->exists) { $tpl->forceFill(['html_template' => $html])->save(); } return $html; } /** * Small JSON endpoints to support cascading selects in editor preview. */ public function clients(Request $request) { $this->authorize('viewAny', EmailTemplate::class); $items = Client::query()->with(['person'])->latest('id')->limit(50)->get(); return response()->json($items->map(fn ($c) => [ 'id' => $c->id, 'label' => trim(($c->person->full_name ?? '').' #'.$c->id) ?: ('Client #'.$c->id), ])); } public function casesForClient(Request $request, Client $client) { $this->authorize('viewAny', EmailTemplate::class); $items = ClientCase::query() ->with(['person']) ->where('client_id', $client->id) ->latest('id') ->limit(50) ->get(); return response()->json($items->map(function ($cs) { $person = $cs->person->full_name ?? ''; $ref = $cs->reference ?? ''; $base = trim(($ref !== '' ? ($ref.' ') : '').'#'.$cs->id); $label = trim(($person !== '' ? ($person.' — ') : '').$base); return [ 'id' => $cs->id, 'label' => $label !== '' ? $label : ('Case #'.$cs->id), ]; })); } public function contractsForCase(Request $request, ClientCase $clientCase) { $this->authorize('viewAny', EmailTemplate::class); $items = Contract::query()->where('client_case_id', $clientCase->id)->latest('id')->limit(50)->get(); return response()->json($items->map(fn ($ct) => [ 'id' => $ct->id, 'label' => trim(($ct->reference ?? '').' #'.$ct->id) ?: ('Contract #'.$ct->id), ])); } }