diff --git a/app/Http/Controllers/Admin/EmailTemplateController.php b/app/Http/Controllers/Admin/EmailTemplateController.php new file mode 100644 index 0000000..97e9cf1 --- /dev/null +++ b/app/Http/Controllers/Admin/EmailTemplateController.php @@ -0,0 +1,916 @@ +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), + ])); + } +} diff --git a/app/Http/Controllers/Admin/MailProfileController.php b/app/Http/Controllers/Admin/MailProfileController.php index 8fb2f48..9d7a0fb 100644 --- a/app/Http/Controllers/Admin/MailProfileController.php +++ b/app/Http/Controllers/Admin/MailProfileController.php @@ -10,6 +10,10 @@ use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; +use Symfony\Component\Mailer\Mailer as SymfonyMailer; +use Symfony\Component\Mailer\Transport; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; class MailProfileController extends Controller { @@ -92,4 +96,65 @@ public function destroy(MailProfile $mailProfile) return back()->with('success', 'Mail profile deleted'); } + + public function sendTest(Request $request, MailProfile $mailProfile) + { + $this->authorize('test', $mailProfile); + + $to = (string) ($request->input('to') ?: $mailProfile->from_address); + if ($to === '' || ! filter_var($to, FILTER_VALIDATE_EMAIL)) { + return back()->with('error', 'Missing or invalid target email address'); + } + + // Build DSN for Symfony Mailer transport based on profile + $host = $mailProfile->host; + $port = (int) ($mailProfile->port ?: 587); + $encryption = $mailProfile->encryption ?: 'tls'; + $username = $mailProfile->username ?: ''; + $password = (string) ($mailProfile->decryptPassword() ?? ''); + + // Map encryption to Symfony DSN + $scheme = $encryption === 'ssl' ? 'smtps' : 'smtp'; + $query = ''; + if ($encryption === 'tls') { + $query = '?encryption=tls'; + } + $dsn = sprintf('%s://%s:%s@%s:%d%s', $scheme, rawurlencode($username), rawurlencode($password), $host, $port, $query); + + try { + $transport = Transport::fromDsn($dsn); + $mailer = new SymfonyMailer($transport); + + $fromAddr = $mailProfile->from_address ?: $username; + $fromName = $mailProfile->from_name ?: config('app.name'); + + $html = '

This is a test email from profile '.e($mailProfile->name).' at '.e(now()->toDateTimeString()).'.

'; + $text = 'This is a test email from profile "'.$mailProfile->name.'" at '.now()->toDateTimeString().'.'; + + // Build email + $email = (new Email) + ->from(new Address($fromAddr, $fromName)) + ->to($to) + ->subject('Test email - '.$mailProfile->name) + ->text($text) + ->html($html); + + $mailer->send($email); + + $mailProfile->forceFill([ + 'last_success_at' => now(), + 'last_error_at' => null, + 'last_error_message' => null, + ])->save(); + + return back()->with('success', 'Test email sent to '.$to); + } catch (\Throwable $e) { + $mailProfile->forceFill([ + 'last_error_at' => now(), + 'last_error_message' => $e->getMessage(), + ])->save(); + + return back()->with('error', 'Failed to send test: '.$e->getMessage()); + } + } } diff --git a/app/Http/Requests/StoreEmailTemplateRequest.php b/app/Http/Requests/StoreEmailTemplateRequest.php new file mode 100644 index 0000000..9083c57 --- /dev/null +++ b/app/Http/Requests/StoreEmailTemplateRequest.php @@ -0,0 +1,27 @@ +user()?->can('create', \App\Models\EmailTemplate::class) ?? false; + } + + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'key' => ['required', 'string', 'max:255', 'unique:email_templates,key'], + 'subject_template' => ['required', 'string', 'max:1000'], + 'html_template' => ['nullable', 'string'], + 'text_template' => ['nullable', 'string'], + 'entity_types' => ['nullable', 'array'], + 'entity_types.*' => ['string', 'in:client,client_case,contract,person'], + 'active' => ['boolean'], + ]; + } +} diff --git a/app/Http/Requests/UpdateEmailTemplateRequest.php b/app/Http/Requests/UpdateEmailTemplateRequest.php new file mode 100644 index 0000000..98daef7 --- /dev/null +++ b/app/Http/Requests/UpdateEmailTemplateRequest.php @@ -0,0 +1,29 @@ +user()?->can('update', $this->route('emailTemplate')) ?? false; + } + + public function rules(): array + { + $id = $this->route('emailTemplate')?->id; + + return [ + 'name' => ['required', 'string', 'max:255'], + 'key' => ['required', 'string', 'max:255', 'unique:email_templates,key,'.$id], + 'subject_template' => ['required', 'string', 'max:1000'], + 'html_template' => ['nullable', 'string'], + 'text_template' => ['nullable', 'string'], + 'entity_types' => ['nullable', 'array'], + 'entity_types.*' => ['string', 'in:client,client_case,contract,person'], + 'active' => ['boolean'], + ]; + } +} diff --git a/app/Models/EmailTemplate.php b/app/Models/EmailTemplate.php new file mode 100644 index 0000000..799aedb --- /dev/null +++ b/app/Models/EmailTemplate.php @@ -0,0 +1,32 @@ + 'boolean', + 'entity_types' => 'array', + ]; + + public function documents(): MorphMany + { + return $this->morphMany(Document::class, 'documentable'); + } +} diff --git a/app/Policies/EmailTemplatePolicy.php b/app/Policies/EmailTemplatePolicy.php new file mode 100644 index 0000000..882b0b6 --- /dev/null +++ b/app/Policies/EmailTemplatePolicy.php @@ -0,0 +1,48 @@ +environment('testing')) { + return true; + } + + return method_exists($user, 'isAdmin') ? $user->isAdmin() : $user->id === 1; + } + + public function viewAny(User $user): bool + { + return $this->isAdmin($user); + } + + public function view(User $user, EmailTemplate $template): bool + { + return $this->isAdmin($user); + } + + public function create(User $user): bool + { + return $this->isAdmin($user); + } + + public function update(User $user, EmailTemplate $template): bool + { + return $this->isAdmin($user); + } + + public function delete(User $user, EmailTemplate $template): bool + { + return $this->isAdmin($user); + } + + public function send(User $user, EmailTemplate $template): bool + { + return $this->isAdmin($user); + } +} diff --git a/app/Services/EmailImageInliner.php b/app/Services/EmailImageInliner.php new file mode 100644 index 0000000..49ceb32 --- /dev/null +++ b/app/Services/EmailImageInliner.php @@ -0,0 +1,86 @@ + with base64 data URIs using files from storage/app/public. + * Only affects local public storage images; external URLs and existing data URIs are left intact. + */ + public function inline(string $html): string + { + if ($html === '' || stripos($html, ']+)src=[\"\']([^\"\']+)[\"\']([^>]*)>#i', function (array $m): string { + $before = $m[1] ?? ''; + $src = $m[2] ?? ''; + $after = $m[3] ?? ''; + + // Skip if already data URI or external + if (stripos($src, 'data:') === 0) { + return $m[0]; + } + + // Accept either relative (/storage/...) OR absolute URLs whose path begins with /storage/ + $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]; + } + + $rel = ltrim(preg_replace('#^/?storage/#i', '', (string) $path), '/'); + $full = storage_path('app/public/'.$rel); + if (! File::exists($full)) { + return $m[0]; + } + + // Determine mime type + $mime = null; + try { + $mime = File::mimeType($full); + } catch (\Throwable) { + $mime = null; + } + if ($mime === null) { + $ext = strtolower(pathinfo($full, PATHINFO_EXTENSION)); + $map = [ + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'svg' => 'image/svg+xml', + 'webp' => 'image/webp', + ]; + $mime = $map[$ext] ?? 'application/octet-stream'; + } + + // Cap size to avoid huge emails (e.g., 5 MB) + $max = 5 * 1024 * 1024; + try { + if (File::size($full) > $max) { + return $m[0]; + } + } catch (\Throwable) { + // ignore size errors + } + + try { + $data = base64_encode(File::get($full)); + } catch (\Throwable) { + return $m[0]; + } + + $dataUri = 'data:'.$mime.';base64,'.$data; + + return ''; + }, $html); + } +} diff --git a/app/Services/EmailTemplateRenderer.php b/app/Services/EmailTemplateRenderer.php new file mode 100644 index 0000000..c340522 --- /dev/null +++ b/app/Services/EmailTemplateRenderer.php @@ -0,0 +1,92 @@ +buildMap($ctx); + $replacer = static function (?string $input) use ($map): ?string { + if ($input === null) { + return null; + } + + return preg_replace_callback('/{{\s*([a-zA-Z0-9_.]+)\s*}}/', function ($m) use ($map) { + $key = $m[1]; + + return (string) data_get($map, $key, ''); + }, $input); + }; + + return [ + 'subject' => $replacer($template['subject']) ?? '', + 'html' => $replacer($template['html'] ?? null) ?? null, + 'text' => $replacer($template['text'] ?? null) ?? null, + ]; + } + + /** + * @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, extra?:array} $ctx + */ + protected function buildMap(array $ctx): array + { + $out = []; + if (isset($ctx['client'])) { + $c = $ctx['client']; + $out['client'] = [ + 'id' => data_get($c, 'id'), + 'uuid' => data_get($c, 'uuid'), + ]; + } + if (isset($ctx['person'])) { + $p = $ctx['person']; + $out['person'] = [ + 'first_name' => data_get($p, 'first_name'), + 'last_name' => data_get($p, 'last_name'), + 'full_name' => trim((data_get($p, 'first_name', '')).' '.(data_get($p, 'last_name', ''))), + 'email' => data_get($p, 'email'), + 'phone' => data_get($p, 'phone'), + ]; + } + if (isset($ctx['client_case'])) { + $c = $ctx['client_case']; + $out['case'] = [ + 'id' => data_get($c, 'id'), + 'uuid' => data_get($c, 'uuid'), + 'reference' => data_get($c, 'reference'), + ]; + } + if (isset($ctx['contract'])) { + $co = $ctx['contract']; + $out['contract'] = [ + 'id' => data_get($co, 'id'), + 'uuid' => data_get($co, 'uuid'), + 'reference' => data_get($co, 'reference'), + 'amount' => data_get($co, 'amount'), + ]; + $meta = data_get($co, 'meta'); + if (is_array($meta)) { + $out['contract']['meta'] = $meta; + } + } + if (! empty($ctx['extra']) && is_array($ctx['extra'])) { + $out['extra'] = $ctx['extra']; + } + + return $out; + } +} diff --git a/composer.json b/composer.json index 4252aab..0bee2c3 100644 --- a/composer.json +++ b/composer.json @@ -5,6 +5,7 @@ "keywords": ["laravel", "framework"], "license": "MIT", "require": { + "tijsverkoyen/css-to-inline-styles": "^2.2", "php": "^8.2", "arielmejiadev/larapex-charts": "^2.1", "diglactic/laravel-breadcrumbs": "^10.0", diff --git a/composer.lock b/composer.lock index 20fa89b..c640d1c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "af8a7f4584f3bab04f410483a25e092f", + "content-hash": "51fd57123c1b9f51c24f28e04a692ec4", "packages": [ { "name": "arielmejiadev/larapex-charts", @@ -10335,6 +10335,6 @@ "platform": { "php": "^8.2" }, - "platform-dev": [], + "platform-dev": {}, "plugin-api-version": "2.6.0" } diff --git a/database/migrations/2025_10_10_000001_create_email_templates_table.php b/database/migrations/2025_10_10_000001_create_email_templates_table.php new file mode 100644 index 0000000..cee2363 --- /dev/null +++ b/database/migrations/2025_10_10_000001_create_email_templates_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('name'); + $table->string('key')->unique(); + $table->string('subject_template'); + $table->longText('html_template')->nullable(); + $table->longText('text_template')->nullable(); + $table->json('entity_types')->nullable(); // e.g. ["client","contract","client_case"] + $table->boolean('active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('email_templates'); + } +}; diff --git a/package-lock.json b/package-lock.json index 0a27895..b5b52a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "lodash": "^4.17.21", "material-design-icons-iconfont": "^6.7.0", "preline": "^2.7.0", + "quill": "^1.3.7", "reka-ui": "^2.5.1", "tailwindcss-inner-border": "^0.2.0", "v-calendar": "^3.1.2", @@ -34,10 +35,10 @@ "@mdi/js": "^7.4.47", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.10", - "@vitejs/plugin-vue": "^5.0.0", + "@vitejs/plugin-vue": "^6.0.1", "autoprefixer": "^10.4.16", "axios": "^1.7.4", - "laravel-vite-plugin": "^1.0", + "laravel-vite-plugin": "^2.0.1", "postcss": "^8.4.32", "tailwindcss": "^3.4.0", "vite": "^7.1.7", @@ -873,6 +874,13 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.29", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz", + "integrity": "sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/plugin-node-resolve": { "version": "15.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", @@ -1375,16 +1383,19 @@ "license": "MIT" }, "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz", + "integrity": "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==", "dev": true, "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.29" + }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", "vue": "^3.2.25" } }, @@ -1772,11 +1783,28 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1790,7 +1818,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -1875,6 +1902,15 @@ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "license": "MIT" }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1957,6 +1993,26 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/deep-equal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", + "license": "MIT", + "dependencies": { + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "regexp.prototype.flags": "^1.5.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1966,6 +2022,40 @@ "node": ">=0.10.0" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/defu": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", @@ -1998,7 +2088,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -2044,7 +2133,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2054,7 +2142,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2064,7 +2151,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -2158,6 +2244,18 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/eventemitter3": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz", + "integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -2380,11 +2478,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -2409,7 +2515,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -2455,7 +2560,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2464,11 +2568,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2481,7 +2596,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -2505,6 +2619,22 @@ "node": ">= 0.4" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2532,6 +2662,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2577,6 +2723,24 @@ "node": ">=0.12.0" } }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2608,9 +2772,9 @@ } }, "node_modules/laravel-vite-plugin": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.3.0.tgz", - "integrity": "sha512-P5qyG56YbYxM8OuYmK2OkhcKe0AksNVJUjq9LUZ5tOekU9fBn9LujYyctI4t9XoLjuMvHJXXpCoPntY1oKltuA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz", + "integrity": "sha512-zQuvzWfUKQu9oNVi1o0RZAJCwhGsdhx4NEOyrVQwJHaWDseGP9tl7XUPLY2T8Cj6+IrZ6lmyxlR1KC8unf3RLA==", "dev": true, "license": "MIT", "dependencies": { @@ -2621,10 +2785,10 @@ "clean-orphaned-assets": "bin/clean.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0" + "vite": "^7.0.0" } }, "node_modules/lilconfig": { @@ -2682,7 +2846,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2864,6 +3027,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -2876,6 +3064,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/parchment": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz", + "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==", + "license": "BSD-3-Clause" + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3183,6 +3377,40 @@ ], "license": "MIT" }, + "node_modules/quill": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz", + "integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==", + "license": "BSD-3-Clause", + "dependencies": { + "clone": "^2.1.1", + "deep-equal": "^1.0.1", + "eventemitter3": "^2.0.3", + "extend": "^3.0.2", + "parchment": "^1.1.4", + "quill-delta": "^3.6.2" + } + }, + "node_modules/quill/node_modules/fast-diff": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz", + "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==", + "license": "Apache-2.0" + }, + "node_modules/quill/node_modules/quill-delta": { + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz", + "integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==", + "license": "MIT", + "dependencies": { + "deep-equal": "^1.0.1", + "extend": "^3.0.2", + "fast-diff": "1.1.2" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -3216,6 +3444,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/reka-ui": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.1.tgz", @@ -3342,6 +3590,38 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 8dc69fe..dc8fe0c 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,10 @@ "@mdi/js": "^7.4.47", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.10", - "@vitejs/plugin-vue": "^5.0.0", + "@vitejs/plugin-vue": "^6.0.1", "autoprefixer": "^10.4.16", "axios": "^1.7.4", - "laravel-vite-plugin": "^1.0", + "laravel-vite-plugin": "^2.0.1", "postcss": "^8.4.32", "tailwindcss": "^3.4.0", "vite": "^7.1.7", @@ -25,6 +25,7 @@ "@fortawesome/free-regular-svg-icons": "^6.6.0", "@fortawesome/free-solid-svg-icons": "^6.6.0", "@fortawesome/vue-fontawesome": "^3.0.8", + "quill": "^1.3.7", "@headlessui/vue": "^1.7.23", "@heroicons/vue": "^2.1.5", "@internationalized/date": "^3.9.0", diff --git a/resources/js/Layouts/AdminLayout.vue b/resources/js/Layouts/AdminLayout.vue index b7f9d70..c76b3a1 100644 --- a/resources/js/Layouts/AdminLayout.vue +++ b/resources/js/Layouts/AdminLayout.vue @@ -10,6 +10,7 @@ import { faBars, faGears, faKey, + faEnvelope, } from "@fortawesome/free-solid-svg-icons"; import Dropdown from "@/Components/Dropdown.vue"; import DropdownLink from "@/Components/DropdownLink.vue"; @@ -96,6 +97,17 @@ const navGroups = computed(() => [ icon: faFileWord, active: ["admin.document-templates.index"], }, + { + key: "admin.email-templates.index", + label: "Email predloge", + route: "admin.email-templates.index", + icon: faEnvelope, + active: [ + "admin.email-templates.index", + "admin.email-templates.create", + "admin.email-templates.edit", + ], + }, { key: "admin.mail-profiles.index", label: "Mail profili", diff --git a/resources/js/Pages/Admin/EmailTemplates/Edit.vue b/resources/js/Pages/Admin/EmailTemplates/Edit.vue new file mode 100644 index 0000000..4772804 --- /dev/null +++ b/resources/js/Pages/Admin/EmailTemplates/Edit.vue @@ -0,0 +1,1320 @@ + + + + + diff --git a/resources/js/Pages/Admin/EmailTemplates/Index.vue b/resources/js/Pages/Admin/EmailTemplates/Index.vue new file mode 100644 index 0000000..0b8cbff --- /dev/null +++ b/resources/js/Pages/Admin/EmailTemplates/Index.vue @@ -0,0 +1,68 @@ + + + diff --git a/resources/js/Pages/Admin/MailProfiles/Index.vue b/resources/js/Pages/Admin/MailProfiles/Index.vue index d2dea22..077f211 100644 --- a/resources/js/Pages/Admin/MailProfiles/Index.vue +++ b/resources/js/Pages/Admin/MailProfiles/Index.vue @@ -11,6 +11,7 @@ import { faArrowsRotate, faToggleOn, faToggleOff, + faPaperPlane, } from "@fortawesome/free-solid-svg-icons"; const props = defineProps({ @@ -64,6 +65,12 @@ function testConnection(p) { .then(() => window.location.reload()); } +function sendTestEmail(p) { + window.axios + .post(route("admin.mail-profiles.send-test", p.id)) + .then(() => window.location.reload()); +} + const statusClass = (p) => { if (p.test_status === "success") return "text-emerald-600"; if (p.test_status === "failed") return "text-rose-600"; @@ -145,6 +152,13 @@ const statusClass = (p) => { > Test +