authorize('update', $emailTemplate); $data = $request->validated(); $emailTemplate->fill($data)->save(); // Move any tmp images referenced in HTML into permanent storage and attach as documents $this->adoptTmpImages($emailTemplate); return redirect()->route('admin.email-templates.edit', $emailTemplate)->with('success', 'Template updated'); } use AuthorizesRequests; public function index(): Response { $this->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'); } /** * Render a quick preview of the email template with the provided context. * Does not persist any changes or inline CSS; intended for fast editor feedback. */ public function preview(Request $request, EmailTemplate $emailTemplate): JsonResponse { $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 for preview, but allow showing them if already accessible // Optionally repair missing img src and attach from template documents for a better preview if (! empty($html)) { $html = $this->repairImgWithoutSrc($html); $html = $this->attachSrcFromTemplateDocuments($emailTemplate, $html); } // Context resolution (shared logic with renderFinalHtml) $ctx = []; if ($id = $request->integer('activity_id')) { $activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id); if ($activity) { $ctx['activity'] = $activity; // Derive base entities from activity when not explicitly provided if ($activity->contract && ! isset($ctx['contract'])) { $ctx['contract'] = $activity->contract; } if ($activity->clientCase && ! isset($ctx['client_case'])) { $ctx['client_case'] = $activity->clientCase; } if (($ctx['client_case'] ?? null) && ! isset($ctx['client'])) { $ctx['client'] = $ctx['client_case']->client; $ctx['person'] = optional($ctx['client'])->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', []); $rendered = $renderer->render([ 'subject' => $subject, 'html' => $html, 'text' => $text, ], $ctx); return response()->json([ 'subject' => $rendered['subject'] ?? $subject, 'html' => (string) ($rendered['html'] ?? $html ?? ''), 'text' => (string) ($rendered['text'] ?? $text ?? ''), ]); } 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 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); // Context resolution $ctx = []; if ($id = $request->integer('activity_id')) { $activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id); if ($activity) { $ctx['activity'] = $activity; if ($activity->contract && ! isset($ctx['contract'])) { $ctx['contract'] = $activity->contract; } if ($activity->clientCase && ! isset($ctx['client_case'])) { $ctx['client_case'] = $activity->clientCase; } if (($ctx['client_case'] ?? null) && ! isset($ctx['client'])) { $ctx['client'] = $ctx['client_case']->client; $ctx['person'] = optional($ctx['client'])->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', []); // Render preview values; we store a minimal snapshot on the log $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'); } // Prepare EmailLog record with queued status $log = new EmailLog; $log->fill([ 'uuid' => (string) \Str::uuid(), 'template_id' => $emailTemplate->id, 'to_email' => $to, 'to_name' => null, 'subject' => (string) ($rendered['subject'] ?? $subject ?? ''), 'body_html_hash' => $rendered['html'] ? hash('sha256', $rendered['html']) : null, 'body_text_preview' => isset($rendered['text']) ? mb_strimwidth((string) $rendered['text'], 0, 4096) : null, 'embed_mode' => (string) $request->input('embed', 'base64'), 'status' => EmailLogStatus::Queued, 'queued_at' => now(), 'client_id' => $ctx['client']->id ?? null, 'client_case_id' => $ctx['client_case']->id ?? null, 'contract_id' => $ctx['contract']->id ?? null, 'extra_context' => $ctx['extra'] ?? null, 'ip' => $request->ip(), ]); $log->save(); // Store bodies in companion table (optional, enabled here) $log->body()->create([ 'body_html' => (string) ($rendered['html'] ?? ''), 'body_text' => (string) ($rendered['text'] ?? ''), 'inline_css' => true, ]); // Dispatch the queued job dispatch(new SendEmailTemplateJob($log->id)); return back()->with('success', 'Test email queued for '.$to); } /** * 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('activity_id')) { $activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id); if ($activity) { $ctx['activity'] = $activity; if ($activity->contract && ! isset($ctx['contract'])) { $ctx['contract'] = $activity->contract; } if ($activity->clientCase && ! isset($ctx['client_case'])) { $ctx['client_case'] = $activity->clientCase; } if (($ctx['client_case'] ?? null) && ! isset($ctx['client'])) { $ctx['client'] = $ctx['client_case']->client; $ctx['person'] = optional($ctx['client'])->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', []); $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, ]); } /** * Delete an attached image Document from the given email template. */ public function deleteImage(Request $request, EmailTemplate $emailTemplate, Document $document) { $this->authorize('update', $emailTemplate); // Ensure the document belongs to this template (polymorphic relation) if ((int) $document->documentable_id !== (int) $emailTemplate->id || $document->documentable_type !== EmailTemplate::class) { return response()->json(['message' => 'Document does not belong to this template.'], 422); } try { // Force delete to remove underlying file as well (Document model handles file deletion on force delete) $document->forceDelete(); } catch (\Throwable $e) { return response()->json(['message' => 'Failed to delete image: '.$e->getMessage()], 500); } return response()->json(['deleted' => true]); } /** * 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), ])); } }