email support
This commit is contained in:
parent
7c7defb6c5
commit
1b615163be
916
app/Http/Controllers/Admin/EmailTemplateController.php
Normal file
916
app/Http/Controllers/Admin/EmailTemplateController.php
Normal file
|
|
@ -0,0 +1,916 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreEmailTemplateRequest;
|
||||||
|
use App\Http\Requests\UpdateEmailTemplateRequest;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\ClientCase;
|
||||||
|
use App\Models\Contract;
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Models\EmailTemplate;
|
||||||
|
use App\Models\MailProfile;
|
||||||
|
use App\Models\Person\Person;
|
||||||
|
use App\Services\EmailTemplateRenderer;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
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;
|
||||||
|
use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles;
|
||||||
|
|
||||||
|
class EmailTemplateController extends Controller
|
||||||
|
{
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <img>, 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 <img src="/storage/..."> (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, '<img') === false) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
$base = (string) (config('app.asset_url') ?: config('app.url'));
|
||||||
|
$host = $base !== '' ? rtrim($base, '/') : $request->getSchemeAndHttpHost();
|
||||||
|
|
||||||
|
return preg_replace_callback('#<img([^>]+)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 '<img'.$before.'src="'.$abs.'"'.$after.'>';
|
||||||
|
}, $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix patterns where an <img ...> tag lacks a src attribute but is immediately followed by a URL.
|
||||||
|
* Example to fix:
|
||||||
|
* <img alt="Logo">\nhttps://domain.tld/storage/email-images/foo.png
|
||||||
|
* becomes:
|
||||||
|
* <img alt="Logo" src="https://domain.tld/storage/email-images/foo.png">
|
||||||
|
* The trailing URL text is removed.
|
||||||
|
*/
|
||||||
|
protected function repairImgWithoutSrc(string $html): string
|
||||||
|
{
|
||||||
|
if ($html === '' || stripos($html, '<img') === false) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to set src on an <img> 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 '<img'.$attrs.' src="'.$url.'">'.$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 <img>
|
||||||
|
$html = preg_replace_callback('#<img([^>]*)>'.$gap.$urlAbs.'#is', $setSrc, $html);
|
||||||
|
$html = preg_replace_callback('#<img([^>]*)>'.$gap.$urlRel.'#is', $setSrc, $html);
|
||||||
|
|
||||||
|
// Case 2: Linked URL after <img> (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 '<img'.$attrs.' src="'.$url.'">'.$between.$anchor;
|
||||||
|
};
|
||||||
|
$html = preg_replace_callback('#<img([^>]*)>'.$gap.$urlAbs.'(\s*<a[^>]+href=["\'][^"\']+["\'][^>]*>.*?</a>)#is', $setSrcAnchor, $html);
|
||||||
|
$html = preg_replace_callback('#<img([^>]*)>'.$gap.$urlRel.'(\s*<a[^>]+href=["\'][^"\']+["\'][^>]*>.*?</a>)#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('#<img(?![^>]*\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('#<img((?![^>]*\bsrc\s*=)[^>]*)>#i', '<img$1 src="'.$onlyUrl.'">', $html, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As a conservative fallback, populate missing <img> 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, '<img') === false) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect candidate image docs from relation if loaded, otherwise query
|
||||||
|
$docs = $tpl->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 '<img'.$attrs.' src="'.$url.'">';
|
||||||
|
};
|
||||||
|
|
||||||
|
$html = preg_replace_callback('#<img([^>]*)>#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/<path>
|
||||||
|
$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),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,10 @@
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
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
|
class MailProfileController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -92,4 +96,65 @@ public function destroy(MailProfile $mailProfile)
|
||||||
|
|
||||||
return back()->with('success', 'Mail profile deleted');
|
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 = '<p>This is a <strong>test email</strong> from profile <code>'.e($mailProfile->name).'</code> at '.e(now()->toDateTimeString()).'.</p>';
|
||||||
|
$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());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
app/Http/Requests/StoreEmailTemplateRequest.php
Normal file
27
app/Http/Requests/StoreEmailTemplateRequest.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreEmailTemplateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Http/Requests/UpdateEmailTemplateRequest.php
Normal file
29
app/Http/Requests/UpdateEmailTemplateRequest.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateEmailTemplateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Models/EmailTemplate.php
Normal file
32
app/Models/EmailTemplate.php
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
|
|
||||||
|
class EmailTemplate extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'key',
|
||||||
|
'subject_template',
|
||||||
|
'html_template',
|
||||||
|
'text_template',
|
||||||
|
'entity_types',
|
||||||
|
'active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'active' => 'boolean',
|
||||||
|
'entity_types' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function documents(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(Document::class, 'documentable');
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Policies/EmailTemplatePolicy.php
Normal file
48
app/Policies/EmailTemplatePolicy.php
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\EmailTemplate;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class EmailTemplatePolicy
|
||||||
|
{
|
||||||
|
protected function isAdmin(User $user): bool
|
||||||
|
{
|
||||||
|
if (app()->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/Services/EmailImageInliner.php
Normal file
86
app/Services/EmailImageInliner.php
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
|
||||||
|
class EmailImageInliner
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Replace <img src="/storage/..."> 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, '<img') === false) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_replace_callback('#<img([^>]+)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 '<img'.$before.'src="'.$dataUri.'"'.$after.'>';
|
||||||
|
}, $html);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/Services/EmailTemplateRenderer.php
Normal file
92
app/Services/EmailTemplateRenderer.php
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\ClientCase;
|
||||||
|
use App\Models\Contract;
|
||||||
|
use App\Models\Person\Person;
|
||||||
|
|
||||||
|
class EmailTemplateRenderer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Render subject and bodies using a simple {{ key }} replacement.
|
||||||
|
* Supported entities: client, person, client_case, contract
|
||||||
|
*
|
||||||
|
* @param array{subject:string, html?:string|null, text?:string|null} $template
|
||||||
|
* @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, extra?:array} $ctx
|
||||||
|
* @return array{subject:string, html?:string, text?:string}
|
||||||
|
*/
|
||||||
|
public function render(array $template, array $ctx): array
|
||||||
|
{
|
||||||
|
$map = $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"keywords": ["laravel", "framework"],
|
"keywords": ["laravel", "framework"],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
|
"tijsverkoyen/css-to-inline-styles": "^2.2",
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"arielmejiadev/larapex-charts": "^2.1",
|
"arielmejiadev/larapex-charts": "^2.1",
|
||||||
"diglactic/laravel-breadcrumbs": "^10.0",
|
"diglactic/laravel-breadcrumbs": "^10.0",
|
||||||
|
|
|
||||||
4
composer.lock
generated
4
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "af8a7f4584f3bab04f410483a25e092f",
|
"content-hash": "51fd57123c1b9f51c24f28e04a692ec4",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "arielmejiadev/larapex-charts",
|
"name": "arielmejiadev/larapex-charts",
|
||||||
|
|
@ -10335,6 +10335,6 @@
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.2"
|
"php": "^8.2"
|
||||||
},
|
},
|
||||||
"platform-dev": [],
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('email_templates', function (Blueprint $table): void {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
328
package-lock.json
generated
328
package-lock.json
generated
|
|
@ -20,6 +20,7 @@
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"material-design-icons-iconfont": "^6.7.0",
|
"material-design-icons-iconfont": "^6.7.0",
|
||||||
"preline": "^2.7.0",
|
"preline": "^2.7.0",
|
||||||
|
"quill": "^1.3.7",
|
||||||
"reka-ui": "^2.5.1",
|
"reka-ui": "^2.5.1",
|
||||||
"tailwindcss-inner-border": "^0.2.0",
|
"tailwindcss-inner-border": "^0.2.0",
|
||||||
"v-calendar": "^3.1.2",
|
"v-calendar": "^3.1.2",
|
||||||
|
|
@ -34,10 +35,10 @@
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"axios": "^1.7.4",
|
"axios": "^1.7.4",
|
||||||
"laravel-vite-plugin": "^1.0",
|
"laravel-vite-plugin": "^2.0.1",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"vite": "^7.1.7",
|
"vite": "^7.1.7",
|
||||||
|
|
@ -873,6 +874,13 @@
|
||||||
"url": "https://opencollective.com/popperjs"
|
"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": {
|
"node_modules/@rollup/plugin-node-resolve": {
|
||||||
"version": "15.3.1",
|
"version": "15.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
|
||||||
|
|
@ -1375,16 +1383,19 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-vue": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
"version": "5.2.4",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz",
|
||||||
"integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
|
"integrity": "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rolldown/pluginutils": "1.0.0-beta.29"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.0.0 || >=20.0.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vite": "^5.0.0 || ^6.0.0",
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
||||||
"vue": "^3.2.25"
|
"vue": "^3.2.25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1772,11 +1783,28 @@
|
||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"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": {
|
"node_modules/call-bind-apply-helpers": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
|
|
@ -1790,7 +1818,6 @@
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
|
@ -1875,6 +1902,15 @@
|
||||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
|
@ -1957,6 +1993,26 @@
|
||||||
"url": "https://github.com/sponsors/kossnocorp"
|
"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": {
|
"node_modules/deepmerge": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
|
|
@ -1966,6 +2022,40 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/defu": {
|
||||||
"version": "6.1.4",
|
"version": "6.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
||||||
|
|
@ -1998,7 +2088,6 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
|
@ -2044,7 +2133,6 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -2054,7 +2142,6 @@
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -2064,7 +2151,6 @@
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
|
|
@ -2158,6 +2244,18 @@
|
||||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
|
|
@ -2380,11 +2478,19 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
|
@ -2409,7 +2515,6 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dunder-proto": "^1.0.1",
|
"dunder-proto": "^1.0.1",
|
||||||
|
|
@ -2455,7 +2560,6 @@
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -2464,11 +2568,22 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/has-symbols": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -2481,7 +2596,6 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-symbols": "^1.0.3"
|
"has-symbols": "^1.0.3"
|
||||||
|
|
@ -2505,6 +2619,22 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"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"
|
"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": {
|
"node_modules/is-extglob": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
|
|
@ -2577,6 +2723,24 @@
|
||||||
"node": ">=0.12.0"
|
"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": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
|
@ -2608,9 +2772,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/laravel-vite-plugin": {
|
"node_modules/laravel-vite-plugin": {
|
||||||
"version": "1.3.0",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz",
|
||||||
"integrity": "sha512-P5qyG56YbYxM8OuYmK2OkhcKe0AksNVJUjq9LUZ5tOekU9fBn9LujYyctI4t9XoLjuMvHJXXpCoPntY1oKltuA==",
|
"integrity": "sha512-zQuvzWfUKQu9oNVi1o0RZAJCwhGsdhx4NEOyrVQwJHaWDseGP9tl7XUPLY2T8Cj6+IrZ6lmyxlR1KC8unf3RLA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -2621,10 +2785,10 @@
|
||||||
"clean-orphaned-assets": "bin/clean.js"
|
"clean-orphaned-assets": "bin/clean.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vite": "^5.0.0 || ^6.0.0"
|
"vite": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
|
|
@ -2682,7 +2846,6 @@
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -2864,6 +3027,31 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/ohash": {
|
||||||
"version": "2.0.11",
|
"version": "2.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||||
|
|
@ -2876,6 +3064,12 @@
|
||||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||||
"license": "BlueOak-1.0.0"
|
"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": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
|
@ -3183,6 +3377,40 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
|
|
@ -3216,6 +3444,26 @@
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/reka-ui": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.1.tgz",
|
||||||
|
|
@ -3342,6 +3590,38 @@
|
||||||
"queue-microtask": "^1.2.2"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"axios": "^1.7.4",
|
"axios": "^1.7.4",
|
||||||
"laravel-vite-plugin": "^1.0",
|
"laravel-vite-plugin": "^2.0.1",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"vite": "^7.1.7",
|
"vite": "^7.1.7",
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.6.0",
|
"@fortawesome/free-regular-svg-icons": "^6.6.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||||
"@fortawesome/vue-fontawesome": "^3.0.8",
|
"@fortawesome/vue-fontawesome": "^3.0.8",
|
||||||
|
"quill": "^1.3.7",
|
||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@heroicons/vue": "^2.1.5",
|
"@heroicons/vue": "^2.1.5",
|
||||||
"@internationalized/date": "^3.9.0",
|
"@internationalized/date": "^3.9.0",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
faBars,
|
faBars,
|
||||||
faGears,
|
faGears,
|
||||||
faKey,
|
faKey,
|
||||||
|
faEnvelope,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import Dropdown from "@/Components/Dropdown.vue";
|
import Dropdown from "@/Components/Dropdown.vue";
|
||||||
import DropdownLink from "@/Components/DropdownLink.vue";
|
import DropdownLink from "@/Components/DropdownLink.vue";
|
||||||
|
|
@ -96,6 +97,17 @@ const navGroups = computed(() => [
|
||||||
icon: faFileWord,
|
icon: faFileWord,
|
||||||
active: ["admin.document-templates.index"],
|
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",
|
key: "admin.mail-profiles.index",
|
||||||
label: "Mail profili",
|
label: "Mail profili",
|
||||||
|
|
|
||||||
1320
resources/js/Pages/Admin/EmailTemplates/Edit.vue
Normal file
1320
resources/js/Pages/Admin/EmailTemplates/Edit.vue
Normal file
File diff suppressed because it is too large
Load Diff
68
resources/js/Pages/Admin/EmailTemplates/Index.vue
Normal file
68
resources/js/Pages/Admin/EmailTemplates/Index.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<script setup>
|
||||||
|
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||||
|
import { Head, Link } from "@inertiajs/vue3";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||||
|
import { faPlus, faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
templates: { type: Array, default: () => [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
function destroyTemplate(tpl) {
|
||||||
|
if (!confirm(`Delete template "${tpl.name}"?`)) return;
|
||||||
|
window.axios
|
||||||
|
.delete(route("admin.email-templates.destroy", tpl.id))
|
||||||
|
.then(() => window.location.reload());
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AdminLayout title="Email predloge">
|
||||||
|
<Head title="Email predloge" />
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-xl font-semibold text-gray-800">Email predloge</h1>
|
||||||
|
<Link
|
||||||
|
:href="route('admin.email-templates.create')"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-500 shadow"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon :icon="faPlus" class="w-4 h-4" /> Nova predloga
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border bg-white overflow-hidden shadow-sm">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left">Ime</th>
|
||||||
|
<th class="px-3 py-2 text-left">Ključ</th>
|
||||||
|
<th class="px-3 py-2 text-left">Entities</th>
|
||||||
|
<th class="px-3 py-2 text-left">Aktivno</th>
|
||||||
|
<th class="px-3 py-2 text-left">Akcije</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="t in templates" :key="t.id" class="border-t last:border-b hover:bg-gray-50">
|
||||||
|
<td class="px-3 py-2 font-medium text-gray-800">{{ t.name }}</td>
|
||||||
|
<td class="px-3 py-2 text-gray-600">{{ t.key }}</td>
|
||||||
|
<td class="px-3 py-2 text-gray-600">{{ (t.entity_types || []).join(', ') }}</td>
|
||||||
|
<td class="px-3 py-2">{{ t.active ? 'da' : 'ne' }}</td>
|
||||||
|
<td class="px-3 py-2 flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
:href="route('admin.email-templates.edit', t.id)"
|
||||||
|
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-indigo-600 border-indigo-300 bg-indigo-50 hover:bg-indigo-100"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon :icon="faPenToSquare" class="w-3.5 h-3.5" /> Uredi
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
@click="destroyTemplate(t)"
|
||||||
|
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-rose-700 border-rose-300 bg-rose-50 hover:bg-rose-100"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon :icon="faTrash" class="w-3.5 h-3.5" /> Izbriši
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
</template>
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
faArrowsRotate,
|
faArrowsRotate,
|
||||||
faToggleOn,
|
faToggleOn,
|
||||||
faToggleOff,
|
faToggleOff,
|
||||||
|
faPaperPlane,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|
@ -64,6 +65,12 @@ function testConnection(p) {
|
||||||
.then(() => window.location.reload());
|
.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) => {
|
const statusClass = (p) => {
|
||||||
if (p.test_status === "success") return "text-emerald-600";
|
if (p.test_status === "success") return "text-emerald-600";
|
||||||
if (p.test_status === "failed") return "text-rose-600";
|
if (p.test_status === "failed") return "text-rose-600";
|
||||||
|
|
@ -145,6 +152,13 @@ const statusClass = (p) => {
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon :icon="faFlask" class="w-3.5 h-3.5" /> Test
|
<FontAwesomeIcon :icon="faFlask" class="w-3.5 h-3.5" /> Test
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
@click="sendTestEmail(p)"
|
||||||
|
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-emerald-700 border-emerald-300 bg-emerald-50 hover:bg-emerald-100"
|
||||||
|
title="Pošlji testni email"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon :icon="faPaperPlane" class="w-3.5 h-3.5" /> Pošlji test
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-indigo-600 border-indigo-300 bg-indigo-50 hover:bg-indigo-100"
|
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-indigo-600 border-indigo-300 bg-indigo-50 hover:bg-indigo-100"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,25 @@
|
||||||
Route::put('mail-profiles/{mailProfile}', [\App\Http\Controllers\Admin\MailProfileController::class, 'update'])->name('mail-profiles.update');
|
Route::put('mail-profiles/{mailProfile}', [\App\Http\Controllers\Admin\MailProfileController::class, 'update'])->name('mail-profiles.update');
|
||||||
Route::post('mail-profiles/{mailProfile}/toggle', [\App\Http\Controllers\Admin\MailProfileController::class, 'toggle'])->name('mail-profiles.toggle');
|
Route::post('mail-profiles/{mailProfile}/toggle', [\App\Http\Controllers\Admin\MailProfileController::class, 'toggle'])->name('mail-profiles.toggle');
|
||||||
Route::post('mail-profiles/{mailProfile}/test', [\App\Http\Controllers\Admin\MailProfileController::class, 'test'])->name('mail-profiles.test');
|
Route::post('mail-profiles/{mailProfile}/test', [\App\Http\Controllers\Admin\MailProfileController::class, 'test'])->name('mail-profiles.test');
|
||||||
|
Route::post('mail-profiles/{mailProfile}/send-test', [\App\Http\Controllers\Admin\MailProfileController::class, 'sendTest'])->name('mail-profiles.send-test');
|
||||||
Route::delete('mail-profiles/{mailProfile}', [\App\Http\Controllers\Admin\MailProfileController::class, 'destroy'])->name('mail-profiles.destroy');
|
Route::delete('mail-profiles/{mailProfile}', [\App\Http\Controllers\Admin\MailProfileController::class, 'destroy'])->name('mail-profiles.destroy');
|
||||||
|
|
||||||
|
// Email templates
|
||||||
|
Route::get('email-templates', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'index'])->name('email-templates.index');
|
||||||
|
Route::get('email-templates/create', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'create'])->name('email-templates.create');
|
||||||
|
Route::post('email-templates', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'store'])->name('email-templates.store');
|
||||||
|
Route::get('email-templates/{emailTemplate}/edit', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'edit'])->name('email-templates.edit');
|
||||||
|
Route::put('email-templates/{emailTemplate}', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'update'])->name('email-templates.update');
|
||||||
|
Route::delete('email-templates/{emailTemplate}', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'destroy'])->name('email-templates.destroy');
|
||||||
|
Route::post('email-templates/{emailTemplate}/preview', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'preview'])->name('email-templates.preview');
|
||||||
|
Route::post('email-templates/{emailTemplate}/send-test', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'sendTest'])->name('email-templates.send-test');
|
||||||
|
Route::post('email-templates/{emailTemplate}/render-final', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'renderFinalHtml'])->name('email-templates.render-final');
|
||||||
|
Route::post('email-templates/upload-image', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'uploadImage'])->name('email-templates.upload-image');
|
||||||
|
Route::post('email-templates/{emailTemplate}/replace-image', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'replaceImage'])->name('email-templates.replace-image');
|
||||||
|
// Cascading selects data
|
||||||
|
Route::get('email-templates-data/clients', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'clients'])->name('email-templates.data.clients');
|
||||||
|
Route::get('email-templates-data/clients/{client}/cases', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'casesForClient'])->name('email-templates.data.cases');
|
||||||
|
Route::get('email-templates-data/cases/{clientCase}/contracts', [\App\Http\Controllers\Admin\EmailTemplateController::class, 'contractsForCase'])->name('email-templates.data.contracts');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Contract document generation (JSON) - protected by auth+verified; permission enforced inside controller service
|
// Contract document generation (JSON) - protected by auth+verified; permission enforced inside controller service
|
||||||
|
|
|
||||||
27
tests/Unit/AttachSrcFromTemplateDocumentsTest.php
Normal file
27
tests/Unit/AttachSrcFromTemplateDocumentsTest.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Admin\EmailTemplateController;
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Models\EmailTemplate;
|
||||||
|
|
||||||
|
it('fills img src from template documents by matching alt', function () {
|
||||||
|
$tpl = new EmailTemplate;
|
||||||
|
// fake relation collection with a single image document
|
||||||
|
$doc = new Document([
|
||||||
|
'path' => 'email-images/logo-uuid.png',
|
||||||
|
'file_name' => 'logo-uuid.png',
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
'name' => 'logo',
|
||||||
|
]);
|
||||||
|
$tpl->setRelation('documents', collect([$doc]));
|
||||||
|
|
||||||
|
$controller = new EmailTemplateController;
|
||||||
|
$ref = new ReflectionClass($controller);
|
||||||
|
$method = $ref->getMethod('attachSrcFromTemplateDocuments');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$input = '<div><img alt="Logo" width="300"></div>';
|
||||||
|
$output = $method->invoke($controller, $tpl, $input);
|
||||||
|
|
||||||
|
expect($output)->toContain('src="/storage/email-images/logo-uuid.png"');
|
||||||
|
});
|
||||||
38
tests/Unit/EmailImageInlinerTest.php
Normal file
38
tests/Unit/EmailImageInlinerTest.php
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\EmailImageInliner;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
|
||||||
|
it('inlines public storage images to base64 data uris', function () {
|
||||||
|
$dir = storage_path('app/public/email-images');
|
||||||
|
if (! is_dir($dir)) {
|
||||||
|
mkdir($dir, 0777, true);
|
||||||
|
}
|
||||||
|
$path = $dir.'/test-inline.png';
|
||||||
|
// a tiny 1x1 PNG
|
||||||
|
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8m4y8AAAAASUVORK5CYII=');
|
||||||
|
File::put($path, $png);
|
||||||
|
|
||||||
|
$html = '<p><img src="/storage/email-images/test-inline.png" alt="x"></p>';
|
||||||
|
$service = new EmailImageInliner;
|
||||||
|
$out = $service->inline($html);
|
||||||
|
|
||||||
|
expect($out)->toContain('src="data:image/png;base64,');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inlines absolute URLs that point to /storage paths', function () {
|
||||||
|
$dir = storage_path('app/public/email-images');
|
||||||
|
if (! is_dir($dir)) {
|
||||||
|
mkdir($dir, 0777, true);
|
||||||
|
}
|
||||||
|
$path = $dir.'/test-abs.png';
|
||||||
|
// a tiny 1x1 PNG
|
||||||
|
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8m4y8AAAAASUVORK5CYII=');
|
||||||
|
File::put($path, $png);
|
||||||
|
|
||||||
|
$html = '<p><img src="http://localhost/storage/email-images/test-abs.png" alt="x"></p>';
|
||||||
|
$service = new EmailImageInliner;
|
||||||
|
$out = $service->inline($html);
|
||||||
|
|
||||||
|
expect($out)->toContain('src="data:image/png;base64,');
|
||||||
|
});
|
||||||
28
tests/Unit/EmailTemplateRendererTest.php
Normal file
28
tests/Unit/EmailTemplateRendererTest.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\EmailTemplateRenderer;
|
||||||
|
|
||||||
|
it('renders placeholders in subject, html and text', function () {
|
||||||
|
$renderer = new EmailTemplateRenderer;
|
||||||
|
|
||||||
|
$template = [
|
||||||
|
'subject' => 'Hello {{ person.full_name }} - {{ contract.reference }}',
|
||||||
|
'html' => '<p>Case: {{ case.uuid }}</p><p>Meta: {{ contract.meta.foo }}</p>',
|
||||||
|
'text' => 'Client: {{ client.uuid }} Extra: {{ extra.note }}',
|
||||||
|
];
|
||||||
|
|
||||||
|
$ctx = [
|
||||||
|
'person' => (object) ['first_name' => 'Jane', 'last_name' => 'Doe', 'email' => 'jane@example.test'],
|
||||||
|
'client' => (object) ['uuid' => 'cl-123'],
|
||||||
|
'client_case' => (object) ['uuid' => 'cc-456', 'reference' => 'REF-1'],
|
||||||
|
'contract' => (object) ['uuid' => 'co-789', 'reference' => 'CON-42', 'meta' => ['foo' => 'bar']],
|
||||||
|
'extra' => ['note' => 'hello'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $renderer->render($template, $ctx);
|
||||||
|
|
||||||
|
expect($result['subject'])->toBe('Hello Jane Doe - CON-42');
|
||||||
|
expect($result['html'])->toContain('Case: cc-456');
|
||||||
|
expect($result['html'])->toContain('Meta: bar');
|
||||||
|
expect($result['text'])->toBe('Client: cl-123 Extra: hello');
|
||||||
|
});
|
||||||
24
tests/Unit/RepairImgWithoutSrcTest.php
Normal file
24
tests/Unit/RepairImgWithoutSrcTest.php
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Admin\EmailTemplateController;
|
||||||
|
|
||||||
|
it('adds src to img when a /storage URL appears shortly after the tag', function () {
|
||||||
|
$controller = new EmailTemplateController;
|
||||||
|
|
||||||
|
$input = <<<HTML
|
||||||
|
<div>
|
||||||
|
<img alt="Logo" width="300" style="display:block;margin:0 auto 16px">
|
||||||
|
\n\n
|
||||||
|
https://localhost/storage/email-images/logo.png
|
||||||
|
</div>
|
||||||
|
HTML;
|
||||||
|
|
||||||
|
$ref = new ReflectionClass($controller);
|
||||||
|
$method = $ref->getMethod('repairImgWithoutSrc');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
/** @var string $output */
|
||||||
|
$output = $method->invoke($controller, $input);
|
||||||
|
|
||||||
|
expect($output)->toContain('src="https://localhost/storage/email-images/logo.png"');
|
||||||
|
});
|
||||||
|
|
@ -17,4 +17,5 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
// Default resolution
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user