917 lines
36 KiB
PHP
917 lines
36 KiB
PHP
<?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),
|
|
]));
|
|
}
|
|
}
|