diff --git a/app/Http/Controllers/Admin/EmailLogController.php b/app/Http/Controllers/Admin/EmailLogController.php
new file mode 100644
index 0000000..2f839e6
--- /dev/null
+++ b/app/Http/Controllers/Admin/EmailLogController.php
@@ -0,0 +1,72 @@
+authorize('viewAny', EmailTemplate::class); // reuse same permission gate for admin area
+
+ $query = EmailLog::query()
+ ->with(['template:id,name'])
+ ->orderByDesc('created_at');
+
+ $status = trim((string) $request->input('status', ''));
+ if ($status !== '') {
+ $query->where('status', $status);
+ }
+ if ($email = trim((string) $request->input('to'))) {
+ $query->where('to_email', 'like', '%'.str_replace(['%', '_'], ['\%', '\_'], $email).'%');
+ }
+ if ($subject = trim((string) $request->input('subject'))) {
+ $query->where('subject', 'like', '%'.str_replace(['%', '_'], ['\%', '\_'], $subject).'%');
+ }
+ if ($templateId = (int) $request->input('template_id')) {
+ $query->where('template_id', $templateId);
+ }
+ if ($from = $request->date('date_from')) {
+ $query->whereDate('created_at', '>=', $from);
+ }
+ if ($to = $request->date('date_to')) {
+ $query->whereDate('created_at', '<=', $to);
+ }
+
+ $logs = $query->paginate(20)->withQueryString();
+ $templates = EmailTemplate::query()->orderBy('name')->get(['id', 'name']);
+
+ return Inertia::render('Admin/EmailLogs/Index', [
+ 'logs' => $logs,
+ 'filters' => [
+ 'status' => $status,
+ 'to' => $email ?? '',
+ 'subject' => $subject ?? '',
+ 'template_id' => $templateId ?: null,
+ 'date_from' => $request->input('date_from'),
+ 'date_to' => $request->input('date_to'),
+ ],
+ 'templates' => $templates,
+ ]);
+ }
+
+ public function show(EmailLog $emailLog): Response
+ {
+ $this->authorize('viewAny', EmailTemplate::class);
+
+ $emailLog->load(['template:id,name', 'body']);
+
+ return Inertia::render('Admin/EmailLogs/Show', [
+ 'log' => $emailLog,
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Admin/EmailTemplateController.php b/app/Http/Controllers/Admin/EmailTemplateController.php
index 97e9cf1..65cebe4 100644
--- a/app/Http/Controllers/Admin/EmailTemplateController.php
+++ b/app/Http/Controllers/Admin/EmailTemplateController.php
@@ -4,26 +4,24 @@
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreEmailTemplateRequest;
-use App\Http\Requests\UpdateEmailTemplateRequest;
+use App\Jobs\SendEmailTemplateJob;
+use App\Models\Activity;
use App\Models\Client;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Document;
+use App\Models\EmailLog;
+use App\Models\EmailLogStatus;
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\JsonResponse;
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;
@@ -31,6 +29,19 @@ class EmailTemplateController extends Controller
{
use AuthorizesRequests;
+ public function update(\App\Http\Requests\UpdateEmailTemplateRequest $request, EmailTemplate $emailTemplate)
+ {
+ $this->authorize('update', $emailTemplate);
+ $data = $request->validated();
+ $emailTemplate->fill($data)->save();
+ // Move any tmp images referenced in HTML into permanent storage and attach as documents
+ $this->adoptTmpImages($emailTemplate);
+
+ return redirect()->route('admin.email-templates.edit', $emailTemplate)->with('success', 'Template updated');
+ }
+
+ use AuthorizesRequests;
+
public function index(): Response
{
$this->authorize('viewAny', EmailTemplate::class);
@@ -59,126 +70,45 @@ public function store(StoreEmailTemplateRequest $request)
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)
+ /**
+ * Render a quick preview of the email template with the provided context.
+ * Does not persist any changes or inline CSS; intended for fast editor feedback.
+ */
+ public function preview(Request $request, EmailTemplate $emailTemplate): JsonResponse
{
$this->authorize('view', $emailTemplate);
- $renderer = app(EmailTemplateRenderer::class);
- $subject = (string) $request->input('subject', $emailTemplate->subject_template);
- $html = (string) $request->input('html', $emailTemplate->html_template);
- $text = (string) $request->input('text', $emailTemplate->text_template);
-
- // 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);
+ // Do not persist tmp images for preview, but allow showing them if already accessible
+ // Optionally repair missing img src and attach from template documents for a better preview
+ if (! empty($html)) {
+ $html = $this->repairImgWithoutSrc($html);
+ $html = $this->attachSrcFromTemplateDocuments($emailTemplate, $html);
+ }
+ // Context resolution (shared logic with renderFinalHtml)
$ctx = [];
+ if ($id = $request->integer('activity_id')) {
+ $activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
+ if ($activity) {
+ $ctx['activity'] = $activity;
+ // Derive base entities from activity when not explicitly provided
+ if ($activity->contract && ! isset($ctx['contract'])) {
+ $ctx['contract'] = $activity->contract;
+ }
+ if ($activity->clientCase && ! isset($ctx['client_case'])) {
+ $ctx['client_case'] = $activity->clientCase;
+ }
+ if (($ctx['client_case'] ?? null) && ! isset($ctx['client'])) {
+ $ctx['client'] = $ctx['client_case']->client;
+ $ctx['person'] = optional($ctx['client'])->person;
+ }
+ }
+ }
if ($id = $request->integer('contract_id')) {
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
if ($contract) {
@@ -217,107 +147,131 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate)
'text' => $text,
], $ctx);
+ return response()->json([
+ 'subject' => $rendered['subject'] ?? $subject,
+ 'html' => (string) ($rendered['html'] ?? $html ?? ''),
+ 'text' => (string) ($rendered['text'] ?? $text ?? ''),
+ ]);
+ }
+
+ public function edit(EmailTemplate $emailTemplate): Response
+ {
+ $this->authorize('update', $emailTemplate);
+ $emailTemplate->load(['documents' => function ($q) {
+ $q->select(['id', 'documentable_id', 'documentable_type', 'name', 'path', 'size', 'created_at']);
+ }]);
+
+ return Inertia::render('Admin/EmailTemplates/Edit', [
+ 'template' => $emailTemplate,
+ ]);
+ }
+
+ public function sendTest(Request $request, EmailTemplate $emailTemplate)
+ {
+ $this->authorize('send', $emailTemplate);
+
+ $renderer = app(EmailTemplateRenderer::class);
+ $subject = (string) ($request->input('subject') ?? $emailTemplate->subject_template);
+ $html = (string) ($request->input('html') ?? $emailTemplate->html_template);
+ $text = (string) ($request->input('text') ?? $emailTemplate->text_template);
+
+ // Adopt tmp images (tmp/email-images) so test email can display images; also persist
+ $html = $this->adoptTmpImagesInHtml($emailTemplate, $html, true);
+
+ // Context resolution
+ $ctx = [];
+ if ($id = $request->integer('activity_id')) {
+ $activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
+ if ($activity) {
+ $ctx['activity'] = $activity;
+ if ($activity->contract && ! isset($ctx['contract'])) {
+ $ctx['contract'] = $activity->contract;
+ }
+ if ($activity->clientCase && ! isset($ctx['client_case'])) {
+ $ctx['client_case'] = $activity->clientCase;
+ }
+ if (($ctx['client_case'] ?? null) && ! isset($ctx['client'])) {
+ $ctx['client'] = $ctx['client_case']->client;
+ $ctx['person'] = optional($ctx['client'])->person;
+ }
+ }
+ }
+ if ($id = $request->integer('contract_id')) {
+ $contract = Contract::query()->with(['clientCase.client.person'])->find($id);
+ if ($contract) {
+ $ctx['contract'] = $contract;
+ if ($contract->clientCase) {
+ $ctx['client_case'] = $contract->clientCase;
+ if ($contract->clientCase->client) {
+ $ctx['client'] = $contract->clientCase->client;
+ $ctx['person'] = optional($contract->clientCase->client)->person;
+ }
+ }
+ }
+ }
+ if (! isset($ctx['client_case']) && ($id = $request->integer('case_id'))) {
+ $case = ClientCase::query()->with(['client.person'])->find($id);
+ if ($case) {
+ $ctx['client_case'] = $case;
+ if ($case->client) {
+ $ctx['client'] = $case->client;
+ $ctx['person'] = optional($case->client)->person;
+ }
+ }
+ }
+ if (! isset($ctx['client']) && ($id = $request->integer('client_id'))) {
+ $client = Client::query()->with(['person'])->find($id);
+ if ($client) {
+ $ctx['client'] = $client;
+ $ctx['person'] = optional($client)->person;
+ }
+ }
+ $ctx['extra'] = (array) $request->input('extra', []);
+
+ // Render preview values; we store a minimal snapshot on the log
+ $rendered = $renderer->render([
+ 'subject' => $subject,
+ 'html' => $html,
+ 'text' => $text,
+ ], $ctx);
+
$to = (string) $request->input('to');
if ($to === '' || ! filter_var($to, FILTER_VALIDATE_EMAIL)) {
return back()->with('error', 'Invalid target email');
}
- // 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');
+ // Prepare EmailLog record with queued status
+ $log = new EmailLog;
+ $log->fill([
+ 'uuid' => (string) \Str::uuid(),
+ 'template_id' => $emailTemplate->id,
+ 'to_email' => $to,
+ 'to_name' => null,
+ 'subject' => (string) ($rendered['subject'] ?? $subject ?? ''),
+ 'body_html_hash' => $rendered['html'] ? hash('sha256', $rendered['html']) : null,
+ 'body_text_preview' => isset($rendered['text']) ? mb_strimwidth((string) $rendered['text'], 0, 4096) : null,
+ 'embed_mode' => (string) $request->input('embed', 'base64'),
+ 'status' => EmailLogStatus::Queued,
+ 'queued_at' => now(),
+ 'client_id' => $ctx['client']->id ?? null,
+ 'client_case_id' => $ctx['client_case']->id ?? null,
+ 'contract_id' => $ctx['contract']->id ?? null,
+ 'extra_context' => $ctx['extra'] ?? null,
+ 'ip' => $request->ip(),
+ ]);
+ $log->save();
- // Prefer the active Mail Profile for sending test emails
- $subject = $rendered['subject'] ?? '';
- $profile = MailProfile::query()
- ->where('active', true)
- ->orderBy('priority')
- ->orderBy('id')
- ->first();
+ // Store bodies in companion table (optional, enabled here)
+ $log->body()->create([
+ 'body_html' => (string) ($rendered['html'] ?? ''),
+ 'body_text' => (string) ($rendered['text'] ?? ''),
+ 'inline_css' => true,
+ ]);
- try {
- if ($profile) {
- $host = $profile->host;
- $port = (int) ($profile->port ?: 587);
- $encryption = $profile->encryption ?: 'tls';
- $username = $profile->username ?: '';
- $password = (string) ($profile->decryptPassword() ?? '');
+ // Dispatch the queued job
+ dispatch(new SendEmailTemplateJob($log->id));
- $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());
- }
+ return back()->with('success', 'Test email queued for '.$to);
}
/**
@@ -338,6 +292,22 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate)
// Context resolution (same as sendTest)
$ctx = [];
+ if ($id = $request->integer('activity_id')) {
+ $activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id);
+ if ($activity) {
+ $ctx['activity'] = $activity;
+ if ($activity->contract && ! isset($ctx['contract'])) {
+ $ctx['contract'] = $activity->contract;
+ }
+ if ($activity->clientCase && ! isset($ctx['client_case'])) {
+ $ctx['client_case'] = $activity->clientCase;
+ }
+ if (($ctx['client_case'] ?? null) && ! isset($ctx['client'])) {
+ $ctx['client'] = $ctx['client_case']->client;
+ $ctx['person'] = optional($ctx['client'])->person;
+ }
+ }
+ }
if ($id = $request->integer('contract_id')) {
$contract = Contract::query()->with(['clientCase.client.person'])->find($id);
if ($contract) {
@@ -712,6 +682,28 @@ public function replaceImage(Request $request, EmailTemplate $emailTemplate)
]);
}
+ /**
+ * Delete an attached image Document from the given email template.
+ */
+ public function deleteImage(Request $request, EmailTemplate $emailTemplate, Document $document)
+ {
+ $this->authorize('update', $emailTemplate);
+
+ // Ensure the document belongs to this template (polymorphic relation)
+ if ((int) $document->documentable_id !== (int) $emailTemplate->id || $document->documentable_type !== EmailTemplate::class) {
+ return response()->json(['message' => 'Document does not belong to this template.'], 422);
+ }
+
+ try {
+ // Force delete to remove underlying file as well (Document model handles file deletion on force delete)
+ $document->forceDelete();
+ } catch (\Throwable $e) {
+ return response()->json(['message' => 'Failed to delete image: '.$e->getMessage()], 500);
+ }
+
+ return response()->json(['deleted' => true]);
+ }
+
/**
* Scan HTML for images stored in /storage/tmp/email-images and move them into a permanent
* location under /storage/email-images, create Document records and update the HTML.
diff --git a/app/Http/Controllers/Admin/MailProfileController.php b/app/Http/Controllers/Admin/MailProfileController.php
index 9d7a0fb..9a62f8c 100644
--- a/app/Http/Controllers/Admin/MailProfileController.php
+++ b/app/Http/Controllers/Admin/MailProfileController.php
@@ -126,14 +126,15 @@ public function sendTest(Request $request, MailProfile $mailProfile)
$mailer = new SymfonyMailer($transport);
$fromAddr = $mailProfile->from_address ?: $username;
- $fromName = $mailProfile->from_name ?: config('app.name');
+ $fromName = (string) ($mailProfile->from_name ?: (config('mail.from.name') ?? config('app.name') ?? ''));
$html = '
This is a test email from profile '.e($mailProfile->name).' at '.e(now()->toDateTimeString()).'.
';
$text = 'This is a test email from profile "'.$mailProfile->name.'" at '.now()->toDateTimeString().'.';
// Build email
+ $fromAddress = $fromName !== '' ? new Address($fromAddr, $fromName) : new Address($fromAddr);
$email = (new Email)
- ->from(new Address($fromAddr, $fromName))
+ ->from($fromAddress)
->to($to)
->subject('Test email - '.$mailProfile->name)
->text($text)
diff --git a/app/Http/Controllers/ClientCaseContoller.php b/app/Http/Controllers/ClientCaseContoller.php
index 09f520b..7591391 100644
--- a/app/Http/Controllers/ClientCaseContoller.php
+++ b/app/Http/Controllers/ClientCaseContoller.php
@@ -21,7 +21,7 @@ class ClientCaseContoller extends Controller
public function index(ClientCase $clientCase, Request $request)
{
$query = $clientCase::query()
- ->with(['person', 'client.person'])
+ ->with(['person.client', 'client.person'])
->where('active', 1)
->when($request->input('search'), function ($que, $search) {
$que->whereHas('person', function ($q) use ($search) {
@@ -251,6 +251,7 @@ public function storeActivity(ClientCase $clientCase, Request $request)
'action_id' => 'exists:\App\Models\Action,id',
'decision_id' => 'exists:\App\Models\Decision,id',
'contract_uuid' => 'nullable|uuid',
+ 'send_auto_mail' => 'sometimes|boolean',
]);
// Map contract_uuid to contract_id within the same client case, if provided
@@ -279,6 +280,23 @@ public function storeActivity(ClientCase $clientCase, Request $request)
logger()->info('Activity successfully inserted', $attributes);
+ // Auto mail dispatch (best-effort)
+ try {
+ $sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
+ $row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
+ $result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag);
+ if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
+ // If template requires contract and user attempted to send, surface a validation message
+ return back()->with('warning', 'Email not queued: required contract is missing for the selected template.');
+ }
+ if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) {
+ return back()->with('warning', 'Email not queued: no eligible client emails to receive auto mails.');
+ }
+ } catch (\Throwable $e) {
+ // Do not fail activity creation due to mailing issues
+ logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
+ }
+
// Stay on the current page (desktop or phone) instead of forcing a redirect to the desktop route.
// Use 303 to align with Inertia's recommended POST/Redirect/GET behavior.
return back(303)->with('success', 'Successful created!');
@@ -1020,7 +1038,7 @@ protected function streamDocumentForDisk(Document $document, bool $inline = true
public function show(ClientCase $clientCase)
{
$case = $clientCase::with([
- 'person' => fn ($que) => $que->with(['addresses', 'phones', 'emails', 'bankAccounts']),
+ 'person' => fn ($que) => $que->with(['addresses', 'phones', 'emails', 'bankAccounts', 'client']),
])->where('active', 1)->findOrFail($clientCase->id);
$types = [
@@ -1174,7 +1192,7 @@ public function show(ClientCase $clientCase)
}
return Inertia::render('Cases/Show', [
- 'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts']))->firstOrFail(),
+ 'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts', 'client']))->firstOrFail(),
'client_case' => $case,
'contracts' => $contracts,
'archive_meta' => [
@@ -1209,11 +1227,17 @@ function ($p) {
'documents' => $mergedDocs,
'contract_types' => \App\Models\ContractType::whereNull('deleted_at')->get(),
'account_types' => \App\Models\AccountType::all(),
- 'actions' => \App\Models\Action::with('decisions')
- /*->when($segmentId, function($q) use($segmentId) {
- $q->where('segment_id', $segmentId)->orWhereNull('segment_id');
- })*/
- ->get(),
+ // Include decisions with auto-mail metadata and the linked email template entity_types for UI logic
+ 'actions' => \App\Models\Action::query()
+ ->with([
+ 'decisions' => function ($q) {
+ $q->select('decisions.id', 'decisions.name', 'decisions.color_tag', 'decisions.auto_mail', 'decisions.email_template_id');
+ },
+ 'decisions.emailTemplate' => function ($q) {
+ $q->select('id', 'name', 'entity_types');
+ },
+ ])
+ ->get(['id', 'name', 'color_tag', 'segment_id']),
'types' => $types,
'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']),
'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']),
diff --git a/app/Http/Controllers/ClientController.php b/app/Http/Controllers/ClientController.php
index 4e90441..924f2be 100644
--- a/app/Http/Controllers/ClientController.php
+++ b/app/Http/Controllers/ClientController.php
@@ -63,7 +63,7 @@ public function show(Client $client, Request $request)
{
$data = $client::query()
- ->with(['person' => fn ($que) => $que->with(['addresses', 'phones', 'bankAccounts', 'emails'])])
+ ->with(['person' => fn ($que) => $que->with(['addresses', 'phones', 'bankAccounts', 'emails', 'client'])])
->findOrFail($client->id);
$types = [
diff --git a/app/Http/Controllers/PersonController.php b/app/Http/Controllers/PersonController.php
index a978d8c..6dba1a2 100644
--- a/app/Http/Controllers/PersonController.php
+++ b/app/Http/Controllers/PersonController.php
@@ -2,32 +2,26 @@
namespace App\Http\Controllers;
-use App\Models\Person\Person;
use App\Models\BankAccount;
+use App\Models\Person\Person;
use Illuminate\Http\Request;
-use Inertia\Inertia;
class PersonController extends Controller
{
//
- public function show(Person $person){
-
- }
+ public function show(Person $person) {}
- public function create(Request $request){
+ public function create(Request $request) {}
- }
+ public function store(Request $request) {}
- public function store(Request $request){
-
- }
-
- public function update(Person $person, Request $request){
+ public function update(Person $person, Request $request)
+ {
$attributes = $request->validate([
'full_name' => 'string|max:255',
'tax_number' => 'nullable|integer',
'social_security_number' => 'nullable|integer',
- 'description' => 'nullable|string|max:500'
+ 'description' => 'nullable|string|max:500',
]);
$person->update($attributes);
@@ -37,17 +31,18 @@ public function update(Person $person, Request $request){
'full_name' => $person->full_name,
'tax_number' => $person->tax_number,
'social_security_number' => $person->social_security_number,
- 'description' => $person->description
- ]
+ 'description' => $person->description,
+ ],
]);
}
- public function createAddress(Person $person, Request $request){
+ public function createAddress(Person $person, Request $request)
+ {
$attributes = $request->validate([
'address' => 'required|string|max:150',
'country' => 'nullable|string',
'type_id' => 'required|integer|exists:address_types,id',
- 'description' => 'nullable|string|max:125'
+ 'description' => 'nullable|string|max:125',
]);
// Dedup: avoid duplicate address per person by (address, country)
@@ -57,7 +52,7 @@ public function createAddress(Person $person, Request $request){
], $attributes);
return response()->json([
- 'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id)
+ 'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id),
]);
}
@@ -67,7 +62,7 @@ public function updateAddress(Person $person, int $address_id, Request $request)
'address' => 'required|string|max:150',
'country' => 'nullable|string',
'type_id' => 'required|integer|exists:address_types,id',
- 'description' => 'nullable|string|max:125'
+ 'description' => 'nullable|string|max:125',
]);
$address = $person->addresses()->with(['type'])->findOrFail($address_id);
@@ -75,7 +70,7 @@ public function updateAddress(Person $person, int $address_id, Request $request)
$address->update($attributes);
return response()->json([
- 'address' => $address
+ 'address' => $address,
]);
}
@@ -83,6 +78,7 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
{
$address = $person->addresses()->findOrFail($address_id);
$address->delete(); // soft delete
+
return response()->json(['status' => 'ok']);
}
@@ -92,7 +88,7 @@ public function createPhone(Person $person, Request $request)
'nu' => 'required|string|max:50',
'country_code' => 'nullable|integer',
'type_id' => 'required|integer|exists:phone_types,id',
- 'description' => 'nullable|string|max:125'
+ 'description' => 'nullable|string|max:125',
]);
// Dedup: avoid duplicate phone per person by (nu, country_code)
@@ -102,7 +98,7 @@ public function createPhone(Person $person, Request $request)
], $attributes);
return response()->json([
- 'phone' => \App\Models\Person\PersonPhone::with(['type'])->findOrFail($phone->id)
+ 'phone' => \App\Models\Person\PersonPhone::with(['type'])->findOrFail($phone->id),
]);
}
@@ -112,7 +108,7 @@ public function updatePhone(Person $person, int $phone_id, Request $request)
'nu' => 'required|string|max:50',
'country_code' => 'nullable|integer',
'type_id' => 'required|integer|exists:phone_types,id',
- 'description' => 'nullable|string|max:125'
+ 'description' => 'nullable|string|max:125',
]);
$phone = $person->phones()->with(['type'])->findOrFail($phone_id);
@@ -120,7 +116,7 @@ public function updatePhone(Person $person, int $phone_id, Request $request)
$phone->update($attributes);
return response()->json([
- 'phone' => $phone
+ 'phone' => $phone,
]);
}
@@ -128,6 +124,7 @@ public function deletePhone(Person $person, int $phone_id, Request $request)
{
$phone = $person->phones()->findOrFail($phone_id);
$phone->delete(); // soft delete
+
return response()->json(['status' => 'ok']);
}
@@ -139,6 +136,7 @@ public function createEmail(Person $person, Request $request)
'is_primary' => 'boolean',
'is_active' => 'boolean',
'valid' => 'boolean',
+ 'receive_auto_mails' => 'sometimes|boolean',
'verified_at' => 'nullable|date',
'preferences' => 'nullable|array',
'meta' => 'nullable|array',
@@ -149,9 +147,7 @@ public function createEmail(Person $person, Request $request)
'value' => $attributes['value'],
], $attributes);
- return response()->json([
- 'email' => \App\Models\Email::findOrFail($email->id)
- ]);
+ return back()->with('success', 'Email added successfully');
}
public function updateEmail(Person $person, int $email_id, Request $request)
@@ -162,6 +158,7 @@ public function updateEmail(Person $person, int $email_id, Request $request)
'is_primary' => 'boolean',
'is_active' => 'boolean',
'valid' => 'boolean',
+ 'receive_auto_mails' => 'sometimes|boolean',
'verified_at' => 'nullable|date',
'preferences' => 'nullable|array',
'meta' => 'nullable|array',
@@ -171,15 +168,14 @@ public function updateEmail(Person $person, int $email_id, Request $request)
$email->update($attributes);
- return response()->json([
- 'email' => $email
- ]);
+ return back()->with('success', 'Email updated successfully');
}
public function deleteEmail(Person $person, int $email_id, Request $request)
{
$email = $person->emails()->findOrFail($email_id);
$email->delete();
+
return response()->json(['status' => 'ok']);
}
@@ -203,7 +199,7 @@ public function createTrr(Person $person, Request $request)
$trr = $person->bankAccounts()->create($attributes);
return response()->json([
- 'trr' => BankAccount::findOrFail($trr->id)
+ 'trr' => BankAccount::findOrFail($trr->id),
]);
}
@@ -227,7 +223,7 @@ public function updateTrr(Person $person, int $trr_id, Request $request)
$trr->update($attributes);
return response()->json([
- 'trr' => $trr
+ 'trr' => $trr,
]);
}
@@ -235,6 +231,7 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
{
$trr = $person->bankAccounts()->findOrFail($trr_id);
$trr->delete();
+
return response()->json(['status' => 'ok']);
}
}
diff --git a/app/Http/Controllers/WorkflowController.php b/app/Http/Controllers/WorkflowController.php
index 63601a0..85f62a3 100644
--- a/app/Http/Controllers/WorkflowController.php
+++ b/app/Http/Controllers/WorkflowController.php
@@ -4,9 +4,9 @@
use App\Models\Action;
use App\Models\Decision;
+use App\Models\EmailTemplate;
use App\Models\Segment;
use Illuminate\Http\Request;
-use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
class WorkflowController extends Controller
@@ -17,6 +17,7 @@ public function index(Request $request)
'actions' => Action::query()->with(['decisions', 'segment'])->withCount('activities')->get(),
'decisions' => Decision::query()->with('actions')->withCount('activities')->get(),
'segments' => Segment::query()->get(),
+ 'email_templates' => EmailTemplate::query()->where('active', true)->get(['id', 'name', 'entity_types']),
]);
}
@@ -41,7 +42,7 @@ public function storeAction(Request $request)
'segment_id' => $attributes['segment_id'] ?? null,
]);
- if (!empty($decisionIds)) {
+ if (! empty($decisionIds)) {
$row->decisions()->sync($decisionIds);
}
});
@@ -59,12 +60,12 @@ public function updateAction(int $id, Request $request)
'segment_id' => 'nullable|integer|exists:segments,id',
'decisions' => 'nullable|array',
'decisions.*.id' => 'required_with:decisions.*|integer|exists:decisions,id',
- 'decisions.*.name' => 'required_with:decisions.*|string|max:50'
+ 'decisions.*.name' => 'required_with:decisions.*|string|max:50',
]);
$decisionIds = collect($attributes['decisions'] ?? [])->pluck('id')->toArray();
- \DB::transaction(function() use ($attributes, $decisionIds, $row) {
+ \DB::transaction(function () use ($attributes, $decisionIds, $row) {
$row->update([
'name' => $attributes['name'],
'color_tag' => $attributes['color_tag'],
@@ -81,6 +82,8 @@ public function storeDecision(Request $request)
$attributes = $request->validate([
'name' => 'required|string|max:50',
'color_tag' => 'nullable|string|max:25',
+ 'auto_mail' => 'sometimes|boolean',
+ 'email_template_id' => 'nullable|integer|exists:email_templates,id',
'actions' => 'nullable|array',
'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id',
'actions.*.name' => 'required_with:actions.*|string|max:50',
@@ -93,9 +96,11 @@ public function storeDecision(Request $request)
$row = Decision::create([
'name' => $attributes['name'],
'color_tag' => $attributes['color_tag'] ?? null,
+ 'auto_mail' => (bool) ($attributes['auto_mail'] ?? false),
+ 'email_template_id' => $attributes['email_template_id'] ?? null,
]);
- if (!empty($actionIds)) {
+ if (! empty($actionIds)) {
$row->actions()->sync($actionIds);
}
});
@@ -110,6 +115,8 @@ public function updateDecision(int $id, Request $request)
$attributes = $request->validate([
'name' => 'required|string|max:50',
'color_tag' => 'nullable|string|max:25',
+ 'auto_mail' => 'sometimes|boolean',
+ 'email_template_id' => 'nullable|integer|exists:email_templates,id',
'actions' => 'nullable|array',
'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id',
'actions.*.name' => 'required_with:actions.*|string|max:50',
@@ -121,6 +128,8 @@ public function updateDecision(int $id, Request $request)
$row->update([
'name' => $attributes['name'],
'color_tag' => $attributes['color_tag'] ?? null,
+ 'auto_mail' => (bool) ($attributes['auto_mail'] ?? false),
+ 'email_template_id' => $attributes['email_template_id'] ?? null,
]);
$row->actions()->sync($actionIds);
});
@@ -139,6 +148,7 @@ public function destroyAction(int $id)
$row->decisions()->detach();
$row->delete();
});
+
return back()->with('success', 'Action deleted successfully!');
}
@@ -153,6 +163,7 @@ public function destroyDecision(int $id)
$row->actions()->detach();
$row->delete();
});
+
return back()->with('success', 'Decision deleted successfully!');
}
}
diff --git a/app/Jobs/SendEmailTemplateJob.php b/app/Jobs/SendEmailTemplateJob.php
new file mode 100644
index 0000000..f877714
--- /dev/null
+++ b/app/Jobs/SendEmailTemplateJob.php
@@ -0,0 +1,60 @@
+find($this->emailLogId);
+ if (! $log) {
+ return;
+ }
+ if ($log->status === EmailLogStatus::Sent) {
+ return;
+ }
+
+ $start = microtime(true);
+ $log->status = EmailLogStatus::Sending;
+ $log->started_at = now();
+ $log->attempt = (int) ($log->attempt ?: 0) + 1;
+ $log->save();
+
+ try {
+ /** @var EmailSender $sender */
+ $sender = app(EmailSender::class);
+ $result = $sender->sendFromLog($log);
+
+ $log->status = EmailLogStatus::Sent;
+ $log->message_id = $result['message_id'] ?? ($log->message_id ?? null);
+ $log->sent_at = now();
+ $log->duration_ms = (int) round((microtime(true) - $start) * 1000);
+ $log->save();
+ } catch (Throwable $e) {
+ $log->status = EmailLogStatus::Failed;
+ $log->failed_at = now();
+ $log->error_message = $e->getMessage();
+ $log->duration_ms = (int) round((microtime(true) - $start) * 1000);
+ $log->save();
+
+ throw $e;
+ }
+ }
+}
diff --git a/app/Models/Decision.php b/app/Models/Decision.php
index b673be5..7cfaa76 100644
--- a/app/Models/Decision.php
+++ b/app/Models/Decision.php
@@ -13,7 +13,14 @@ class Decision extends Model
/** @use HasFactory<\Database\Factories\DecisionFactory> */
use HasFactory;
- protected $fillable = ['name', 'color_tag'];
+ protected $fillable = ['name', 'color_tag', 'auto_mail', 'email_template_id'];
+
+ protected function casts(): array
+ {
+ return [
+ 'auto_mail' => 'boolean',
+ ];
+ }
public function actions(): BelongsToMany
{
@@ -29,4 +36,9 @@ public function activities(): HasMany
{
return $this->hasMany(\App\Models\Activity::class);
}
+
+ public function emailTemplate(): BelongsTo
+ {
+ return $this->belongsTo(\App\Models\EmailTemplate::class, 'email_template_id');
+ }
}
diff --git a/app/Models/Email.php b/app/Models/Email.php
index 93e2ae2..a3fe24d 100644
--- a/app/Models/Email.php
+++ b/app/Models/Email.php
@@ -18,6 +18,7 @@ class Email extends Model
'is_primary',
'is_active',
'valid',
+ 'receive_auto_mails',
'verified_at',
'preferences',
'meta',
@@ -27,6 +28,7 @@ class Email extends Model
'is_primary' => 'boolean',
'is_active' => 'boolean',
'valid' => 'boolean',
+ 'receive_auto_mails' => 'boolean',
'verified_at' => 'datetime',
'preferences' => 'array',
'meta' => 'array',
diff --git a/app/Models/EmailLog.php b/app/Models/EmailLog.php
new file mode 100644
index 0000000..1648638
--- /dev/null
+++ b/app/Models/EmailLog.php
@@ -0,0 +1,86 @@
+ EmailLogStatus::class,
+ 'cc' => 'array',
+ 'bcc' => 'array',
+ 'to_recipients' => 'array',
+ 'attachments' => 'array',
+ 'headers' => 'array',
+ 'extra_context' => 'array',
+ 'attempt' => 'integer',
+ 'duration_ms' => 'integer',
+ 'queued_at' => 'datetime',
+ 'started_at' => 'datetime',
+ 'sent_at' => 'datetime',
+ 'failed_at' => 'datetime',
+ ];
+ }
+
+ public function template(): BelongsTo
+ {
+ return $this->belongsTo(EmailTemplate::class, 'template_id');
+ }
+
+ public function body(): HasOne
+ {
+ return $this->hasOne(EmailLogBody::class, 'email_log_id');
+ }
+}
diff --git a/app/Models/EmailLogBody.php b/app/Models/EmailLogBody.php
new file mode 100644
index 0000000..40a2635
--- /dev/null
+++ b/app/Models/EmailLogBody.php
@@ -0,0 +1,28 @@
+ 'boolean',
+ ];
+ }
+
+ public function log(): BelongsTo
+ {
+ return $this->belongsTo(EmailLog::class, 'email_log_id');
+ }
+}
diff --git a/app/Services/AutoMailDispatcher.php b/app/Services/AutoMailDispatcher.php
new file mode 100644
index 0000000..c8856a6
--- /dev/null
+++ b/app/Services/AutoMailDispatcher.php
@@ -0,0 +1,122 @@
+ true, 'log_id' => int] or ['skipped' => 'reason'].
+ */
+ public function maybeQueue(Activity $activity, bool $sendFlag = true): array
+ {
+ $decision = $activity->decision;
+ if (! $sendFlag || ! $decision || ! $decision->auto_mail || ! $decision->email_template_id) {
+ return ['skipped' => 'disabled'];
+ }
+
+ /** @var EmailTemplate|null $template */
+ $template = EmailTemplate::query()->find($decision->email_template_id);
+ if (! $template || ! $template->active) {
+ return ['skipped' => 'no-template'];
+ }
+
+ // Resolve context
+ $clientCase = $activity->clientCase; /** @var ClientCase|null $clientCase */
+ $contract = $activity->contract; /** @var Contract|null $contract */
+ $client = $clientCase?->client; /** @var Client|null $client */
+ $person = $clientCase?->person; /** @var \App\Models\Person\Person|null $person */
+
+ // Validate required entity types from template
+ $required = (array) ($template->entity_types ?? []);
+ if (in_array('client', $required, true) && ! $client) {
+ return ['skipped' => 'missing-client'];
+ }
+ if (in_array('client_case', $required, true) && ! $clientCase) {
+ return ['skipped' => 'missing-client-case'];
+ }
+ if (in_array('contract', $required, true) && ! $contract) {
+ return ['skipped' => 'missing-contract'];
+ }
+ if (in_array('person', $required, true) && ! $person) {
+ return ['skipped' => 'missing-person'];
+ }
+
+ // Resolve eligible recipients: client's person emails with receive_auto_mails = true
+ $recipients = [];
+ if ($client && $client->person) {
+ $recipients = Email::query()
+ ->where('person_id', $client->person->id)
+ ->where('is_active', true)
+ ->where('receive_auto_mails', true)
+ ->pluck('value')
+ ->map(fn ($v) => strtolower(trim((string) $v)))
+ ->filter(fn ($v) => filter_var($v, FILTER_VALIDATE_EMAIL))
+ ->unique()
+ ->values()
+ ->all();
+ }
+ if (empty($recipients)) {
+ return ['skipped' => 'no-recipients'];
+ }
+
+ // Ensure related names are available without extra queries
+ $activity->loadMissing(['action', 'decision']);
+
+ // Render content
+ $rendered = $this->renderer->render([
+ 'subject' => (string) $template->subject_template,
+ 'html' => (string) $template->html_template,
+ 'text' => (string) $template->text_template,
+ ], [
+ 'client' => $client,
+ 'client_case' => $clientCase,
+ 'contract' => $contract,
+ 'person' => $person,
+ 'activity' => $activity,
+ 'extra' => [],
+ ]);
+
+ // Create the log and body
+ $log = new EmailLog;
+ $log->fill([
+ 'uuid' => (string) \Str::uuid(),
+ 'template_id' => $template->id,
+ 'to_email' => (string) ($recipients[0] ?? ''),
+ 'to_recipients' => $recipients,
+ 'subject' => (string) ($rendered['subject'] ?? $template->subject_template ?? ''),
+ 'body_html_hash' => $rendered['html'] ? hash('sha256', $rendered['html']) : null,
+ 'body_text_preview' => isset($rendered['text']) ? mb_strimwidth((string) $rendered['text'], 0, 4096) : null,
+ 'embed_mode' => 'base64',
+ 'status' => EmailLogStatus::Queued,
+ 'queued_at' => now(),
+ 'client_id' => $client?->id,
+ 'client_case_id' => $clientCase?->id,
+ 'contract_id' => $contract?->id,
+ ]);
+ $log->save();
+
+ $log->body()->create([
+ 'body_html' => (string) ($rendered['html'] ?? ''),
+ 'body_text' => (string) ($rendered['text'] ?? ''),
+ 'inline_css' => true,
+ ]);
+
+ dispatch(new SendEmailTemplateJob($log->id));
+
+ return ['queued' => true, 'log_id' => $log->id];
+ }
+}
diff --git a/app/Services/EmailSender.php b/app/Services/EmailSender.php
new file mode 100644
index 0000000..bd11b46
--- /dev/null
+++ b/app/Services/EmailSender.php
@@ -0,0 +1,192 @@
+ string|null].
+ * Throws on transport errors so the Job can retry.
+ */
+ public function sendFromLog(EmailLog $log): array
+ {
+ // Resolve sending profile
+ $profile = null;
+ if ($log->mail_profile_id) {
+ $profile = MailProfile::query()->find($log->mail_profile_id);
+ }
+ if (! $profile) {
+ $profile = MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first();
+ }
+
+ $embed = $log->embed_mode ?: 'base64';
+
+ $subject = (string) $log->subject;
+ $html = (string) optional($log->body)->body_html ?? '';
+ $text = (string) optional($log->body)->body_text ?? '';
+
+ // Inline CSS and handle images similarly to controller
+ if ($html !== '') {
+ if ($embed === 'base64') {
+ try {
+ $imageInliner = app(\App\Services\EmailImageInliner::class);
+ $html = $imageInliner->inline($html);
+ } catch (\Throwable $e) {
+ }
+ } else {
+ // Best effort absolutize /storage URLs using app.url
+ $base = (string) (config('app.asset_url') ?: config('app.url'));
+ $host = $base !== '' ? rtrim($base, '/') : null;
+ if ($host) {
+ $html = preg_replace_callback('#
]+)src=["\']([^"\']+)["\']([^>]*)>#i', function (array $m) use ($host) {
+ $before = $m[1] ?? '';
+ $src = $m[2] ?? '';
+ $after = $m[3] ?? '';
+ $path = $src;
+ if (preg_match('#^https?://#i', $src)) {
+ $parts = parse_url($src);
+ $path = $parts['path'] ?? '';
+ if (! preg_match('#^/?storage/#i', (string) $path)) {
+ return $m[0];
+ }
+ } else {
+ if (! preg_match('#^/?storage/#i', (string) $path)) {
+ return $m[0];
+ }
+ }
+ $rel = '/'.ltrim(preg_replace('#^/?storage/#i', 'storage/', (string) $path), '/');
+ $abs = rtrim($host, '/').$rel;
+
+ return '
';
+ }, $html);
+ }
+ }
+ try {
+ $inliner = new CssToInlineStyles;
+ $html = $inliner->convert($html);
+ } catch (\Throwable $e) {
+ }
+ }
+
+ // Transport setup (Symfony Mailer preferred when profile exists)
+ $messageId = null;
+ 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 = (string) ($log->from_email ?: ($profile->from_address ?: ($username ?: (config('mail.from.address') ?? ''))));
+ $fromName = (string) ($log->from_name ?: ($profile->from_name ?: (config('mail.from.name') ?? config('app.name') ?? '')));
+
+ // Build email with safe Address instances (Symfony Address does not allow null name)
+ $fromAddress = $fromName !== ''
+ ? new Address($fromAddr ?: $log->to_email, $fromName)
+ : new Address($fromAddr ?: $log->to_email);
+ $toAddress = (string) ($log->to_name ?? '') !== ''
+ ? new Address($log->to_email, (string) $log->to_name)
+ : new Address($log->to_email);
+
+ $email = (new Email)
+ ->from($fromAddress)
+ ->subject($subject);
+
+ // If multiple recipients are present, address to all; otherwise single to
+ $toList = (array) ($log->to_recipients ?? []);
+ if (! empty($toList)) {
+ $addresses = [];
+ foreach ($toList as $addr) {
+ $addr = trim((string) $addr);
+ if ($addr !== '' && filter_var($addr, FILTER_VALIDATE_EMAIL)) {
+ $addresses[] = new Address($addr);
+ }
+ }
+ if (! empty($addresses)) {
+ $email->to(...$addresses);
+ } else {
+ $email->to($toAddress);
+ }
+ } else {
+ $email->to($toAddress);
+ }
+
+ if (! empty($text)) {
+ $email->text($text);
+ }
+ if (! empty($html)) {
+ $email->html($html);
+ }
+ if (! empty($log->reply_to)) {
+ $email->replyTo($log->reply_to);
+ }
+
+ $mailer->send($email);
+ $headers = $email->getHeaders();
+ $messageIdHeader = $headers->get('Message-ID');
+ $messageId = $messageIdHeader ? $messageIdHeader->getBodyAsString() : null;
+ } else {
+ // Fallback to Laravel mailer
+ if (! empty($html)) {
+ \Mail::html($html, function ($message) use ($log, $subject, $text) {
+ $toList = (array) ($log->to_recipients ?? []);
+ if (! empty($toList)) {
+ $message->to($toList);
+ } else {
+ $toName = (string) ($log->to_name ?? '');
+ if ($toName !== '') {
+ $message->to($log->to_email, $toName);
+ } else {
+ $message->to($log->to_email);
+ }
+ }
+ $message->subject($subject);
+ if (! empty($log->reply_to)) {
+ $message->replyTo($log->reply_to);
+ }
+ if (! empty($text)) {
+ // Provide a plain text alternative when available
+ $message->text($text);
+ }
+ });
+ } else {
+ \Mail::raw($text ?: '', function ($message) use ($log, $subject) {
+ $toList = (array) ($log->to_recipients ?? []);
+ if (! empty($toList)) {
+ $message->to($toList);
+ } else {
+ $toName = (string) ($log->to_name ?? '');
+ if ($toName !== '') {
+ $message->to($log->to_email, $toName);
+ } else {
+ $message->to($log->to_email);
+ }
+ }
+ $message->subject($subject);
+ if (! empty($log->reply_to)) {
+ $message->replyTo($log->reply_to);
+ }
+ });
+ }
+ }
+
+ return ['message_id' => $messageId];
+ }
+}
diff --git a/app/Services/EmailTemplateRenderer.php b/app/Services/EmailTemplateRenderer.php
index c340522..b0330cf 100644
--- a/app/Services/EmailTemplateRenderer.php
+++ b/app/Services/EmailTemplateRenderer.php
@@ -2,19 +2,21 @@
namespace App\Services;
+use App\Models\Activity;
use App\Models\Client;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Person\Person;
+use Carbon\Carbon;
class EmailTemplateRenderer
{
/**
* Render subject and bodies using a simple {{ key }} replacement.
- * Supported entities: client, person, client_case, contract
+ * Supported entities: client, person, client_case, contract, activity
*
* @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
+ * @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, activity?:Activity, extra?:array} $ctx
* @return array{subject:string, html?:string, text?:string}
*/
public function render(array $template, array $ctx): array
@@ -40,16 +42,66 @@ public function render(array $template, array $ctx): array
}
/**
- * @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, extra?:array} $ctx
+ * @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, activity?:Activity, extra?:array} $ctx
*/
protected function buildMap(array $ctx): array
{
+ $formatDateEu = static function ($value): string {
+ if ($value === null || $value === '') {
+ return '';
+ }
+ try {
+ if ($value instanceof \DateTimeInterface) {
+ return Carbon::instance($value)->format('d.m.Y');
+ }
+
+ // Accept common formats (Y-m-d, Y-m-d H:i:s, etc.)
+ return Carbon::parse((string) $value)->format('d.m.Y');
+ } catch (\Throwable $e) {
+ return (string) $value;
+ }
+ };
+
+ $formatMoneyEu = static function ($value): string {
+ if ($value === null || $value === '') {
+ return '';
+ }
+ $num = null;
+ if (is_numeric($value)) {
+ $num = (float) $value;
+ } elseif (is_string($value)) {
+ // Try to normalize string numbers like "1,234.56" or "1.234,56"
+ $normalized = str_replace([' ', '\u{00A0}'], '', $value);
+ $normalized = str_replace(['.', ','], ['.', '.'], $normalized);
+ $num = is_numeric($normalized) ? (float) $normalized : null;
+ }
+ if ($num === null) {
+ return (string) $value;
+ }
+
+ return number_format($num, 2, ',', '.').' €';
+ };
+
$out = [];
if (isset($ctx['client'])) {
$c = $ctx['client'];
$out['client'] = [
'id' => data_get($c, 'id'),
'uuid' => data_get($c, 'uuid'),
+ // Expose nested person for {{ client.person.full_name }} etc.
+ 'person' => [
+ 'first_name' => data_get($c, 'person.first_name'),
+ 'last_name' => data_get($c, 'person.last_name'),
+ 'full_name' => (function ($c) {
+ $fn = (string) data_get($c, 'person.first_name', '');
+ $ln = (string) data_get($c, 'person.last_name', '');
+ $stored = data_get($c, 'person.full_name');
+
+ return (string) ($stored ?: trim(trim($fn.' '.$ln)));
+ })($c),
+ 'email' => data_get($c, 'person.email'),
+ 'phone' => data_get($c, 'person.phone'),
+ ],
];
}
if (isset($ctx['person'])) {
@@ -68,6 +120,23 @@ protected function buildMap(array $ctx): array
'id' => data_get($c, 'id'),
'uuid' => data_get($c, 'uuid'),
'reference' => data_get($c, 'reference'),
+ // Expose nested person for {{ case.person.full_name }}; prefer direct relation, fallback to client.person
+ 'person' => [
+ 'first_name' => data_get($c, 'person.first_name') ?? data_get($c, 'client.person.first_name'),
+ 'last_name' => data_get($c, 'person.last_name') ?? data_get($c, 'client.person.last_name'),
+ 'full_name' => (function ($c) {
+ $stored = data_get($c, 'person.full_name') ?? data_get($c, 'client.person.full_name');
+ if ($stored) {
+ return (string) $stored;
+ }
+ $fn = (string) (data_get($c, 'person.first_name') ?? data_get($c, 'client.person.first_name') ?? '');
+ $ln = (string) (data_get($c, 'person.last_name') ?? data_get($c, 'client.person.last_name') ?? '');
+
+ return trim(trim($fn.' '.$ln));
+ })($c),
+ 'email' => data_get($c, 'person.email') ?? data_get($c, 'client.person.email'),
+ 'phone' => data_get($c, 'person.phone') ?? data_get($c, 'client.person.phone'),
+ ],
];
}
if (isset($ctx['contract'])) {
@@ -76,13 +145,30 @@ protected function buildMap(array $ctx): array
'id' => data_get($co, 'id'),
'uuid' => data_get($co, 'uuid'),
'reference' => data_get($co, 'reference'),
- 'amount' => data_get($co, 'amount'),
+ // Format amounts in EU style for emails
+ 'amount' => $formatMoneyEu(data_get($co, 'amount')),
];
$meta = data_get($co, 'meta');
if (is_array($meta)) {
$out['contract']['meta'] = $meta;
}
}
+ if (isset($ctx['activity'])) {
+ $a = $ctx['activity'];
+ $out['activity'] = [
+ 'id' => data_get($a, 'id'),
+ 'note' => data_get($a, 'note'),
+ // EU formatted date and amount by default in emails
+ 'due_date' => $formatDateEu(data_get($a, 'due_date')),
+ 'amount' => $formatMoneyEu(data_get($a, 'amount')),
+ 'action' => [
+ 'name' => data_get($a, 'action.name'),
+ ],
+ 'decision' => [
+ 'name' => data_get($a, 'decision.name'),
+ ],
+ ];
+ }
if (! empty($ctx['extra']) && is_array($ctx['extra'])) {
$out['extra'] = $ctx['extra'];
}
diff --git a/database/migrations/2025_10_11_174805_create_email_logs_table.php b/database/migrations/2025_10_11_174805_create_email_logs_table.php
new file mode 100644
index 0000000..9033a04
--- /dev/null
+++ b/database/migrations/2025_10_11_174805_create_email_logs_table.php
@@ -0,0 +1,73 @@
+id();
+ $table->uuid('uuid')->unique();
+ $table->foreignId('template_id')->nullable()->constrained('email_templates')->nullOnDelete();
+ $table->foreignId('mail_profile_id')->nullable()->constrained('mail_profiles')->nullOnDelete();
+ $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
+
+ $table->string('message_id')->nullable()->unique();
+ $table->string('correlation_id', 100)->nullable();
+
+ $table->string('to_email');
+ $table->string('to_name')->nullable();
+ $table->json('cc')->nullable();
+ $table->json('bcc')->nullable();
+ $table->string('from_email')->nullable();
+ $table->string('from_name')->nullable();
+ $table->string('reply_to')->nullable();
+
+ $table->string('subject', 512);
+ $table->string('body_html_hash', 64)->nullable();
+ $table->text('body_text_preview')->nullable();
+ $table->json('attachments')->nullable();
+ $table->string('embed_mode', 16)->default('base64');
+
+ $table->string('status', 20)->index();
+ $table->string('error_code', 100)->nullable();
+ $table->text('error_message')->nullable();
+ $table->string('transport')->nullable();
+ $table->json('headers')->nullable();
+ $table->unsignedSmallInteger('attempt')->default(1);
+ $table->unsignedInteger('duration_ms')->nullable();
+
+ $table->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete();
+ $table->foreignId('client_case_id')->nullable()->constrained('client_cases')->nullOnDelete();
+ $table->foreignId('contract_id')->nullable()->constrained('contracts')->nullOnDelete();
+ $table->json('extra_context')->nullable();
+
+ $table->timestamp('queued_at')->nullable();
+ $table->timestamp('started_at')->nullable();
+ $table->timestamp('sent_at')->nullable();
+ $table->timestamp('failed_at')->nullable();
+ $table->string('ip', 45)->nullable();
+
+ $table->timestamps();
+
+ $table->index(['template_id', 'created_at']);
+ $table->index(['to_email', 'created_at']);
+ $table->index(['status', 'created_at']);
+ $table->index(['client_id', 'client_case_id', 'contract_id']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('email_logs');
+ }
+};
diff --git a/database/migrations/2025_10_11_174816_create_email_log_bodies_table.php b/database/migrations/2025_10_11_174816_create_email_log_bodies_table.php
new file mode 100644
index 0000000..f86d97d
--- /dev/null
+++ b/database/migrations/2025_10_11_174816_create_email_log_bodies_table.php
@@ -0,0 +1,31 @@
+id();
+ $table->foreignId('email_log_id')->unique()->constrained('email_logs')->cascadeOnDelete();
+ $table->longText('body_html')->nullable();
+ $table->longText('body_text')->nullable();
+ $table->boolean('inline_css')->default(true);
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('email_log_bodies');
+ }
+};
diff --git a/database/migrations/2025_10_11_190001_add_auto_mail_and_template_to_decisions_table.php b/database/migrations/2025_10_11_190001_add_auto_mail_and_template_to_decisions_table.php
new file mode 100644
index 0000000..666d5c4
--- /dev/null
+++ b/database/migrations/2025_10_11_190001_add_auto_mail_and_template_to_decisions_table.php
@@ -0,0 +1,32 @@
+boolean('auto_mail')->default(false)->after('color_tag');
+ }
+ if (! Schema::hasColumn('decisions', 'email_template_id')) {
+ $table->foreignId('email_template_id')->nullable()->after('auto_mail')->constrained('email_templates')->nullOnDelete();
+ }
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('decisions', function (Blueprint $table) {
+ if (Schema::hasColumn('decisions', 'email_template_id')) {
+ $table->dropConstrainedForeignId('email_template_id');
+ }
+ if (Schema::hasColumn('decisions', 'auto_mail')) {
+ $table->dropColumn('auto_mail');
+ }
+ });
+ }
+};
diff --git a/database/migrations/2025_10_11_190010_add_recipients_and_receive_auto_mails.php b/database/migrations/2025_10_11_190010_add_recipients_and_receive_auto_mails.php
new file mode 100644
index 0000000..6191b8a
--- /dev/null
+++ b/database/migrations/2025_10_11_190010_add_recipients_and_receive_auto_mails.php
@@ -0,0 +1,38 @@
+json('to_recipients')->nullable()->after('to_email');
+ }
+ });
+
+ Schema::table('emails', function (Blueprint $table) {
+ if (! Schema::hasColumn('emails', 'receive_auto_mails')) {
+ $table->boolean('receive_auto_mails')->default(false)->after('is_active');
+ }
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('email_logs', function (Blueprint $table) {
+ if (Schema::hasColumn('email_logs', 'to_recipients')) {
+ $table->dropColumn('to_recipients');
+ }
+ });
+
+ Schema::table('emails', function (Blueprint $table) {
+ if (Schema::hasColumn('emails', 'receive_auto_mails')) {
+ $table->dropColumn('receive_auto_mails');
+ }
+ });
+ }
+};
diff --git a/resources/js/Components/EmailCreateForm.vue b/resources/js/Components/EmailCreateForm.vue
index 79d8dd5..26d4c99 100644
--- a/resources/js/Components/EmailCreateForm.vue
+++ b/resources/js/Components/EmailCreateForm.vue
@@ -1,12 +1,12 @@
-
+
diff --git a/resources/js/Components/PersonInfoGrid.vue b/resources/js/Components/PersonInfoGrid.vue
index 8f44857..0ad966a 100644
--- a/resources/js/Components/PersonInfoGrid.vue
+++ b/resources/js/Components/PersonInfoGrid.vue
@@ -350,6 +350,7 @@ const getTRRs = (p) => {
@close="drawerAddEmail = false"
:person="person"
:types="types.email_types ?? []"
+ :is-client-context="!!person?.client"
/>
{
:person="person"
:types="types.email_types ?? []"
:id="editEmailId"
+ :is-client-context="!!person?.client"
/>
diff --git a/resources/js/Layouts/AdminLayout.vue b/resources/js/Layouts/AdminLayout.vue
index c76b3a1..544493d 100644
--- a/resources/js/Layouts/AdminLayout.vue
+++ b/resources/js/Layouts/AdminLayout.vue
@@ -108,6 +108,13 @@ const navGroups = computed(() => [
"admin.email-templates.edit",
],
},
+ {
+ key: "admin.email-logs.index",
+ label: "Email dnevniki",
+ route: "admin.email-logs.index",
+ icon: faEnvelope,
+ active: ["admin.email-logs.index", "admin.email-logs.show"],
+ },
{
key: "admin.mail-profiles.index",
label: "Mail profili",
diff --git a/resources/js/Pages/Admin/EmailLogs/Index.vue b/resources/js/Pages/Admin/EmailLogs/Index.vue
new file mode 100644
index 0000000..8704831
--- /dev/null
+++ b/resources/js/Pages/Admin/EmailLogs/Index.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Date |
+ Status |
+ To |
+ Subject |
+ Template |
+ Duration |
+ \# |
+
+
+
+
+ | {{ new Date(log.created_at).toLocaleString() }} |
+ {{ log.status }} |
+ {{ log.to_email }} |
+ {{ log.subject }} |
+ {{ log.template?.name || '-' }} |
+ {{ log.duration_ms ? log.duration_ms + ' ms' : '-' }} |
+ Open |
+
+
+
+
+
+
Showing {{ logs.from }}-{{ logs.to }} of {{ logs.total }}
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/Pages/Admin/EmailLogs/Show.vue b/resources/js/Pages/Admin/EmailLogs/Show.vue
new file mode 100644
index 0000000..b25fa5d
--- /dev/null
+++ b/resources/js/Pages/Admin/EmailLogs/Show.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+ Back
+
Email Log #{{ props.log.id }}
+
+
Created: {{ new Date(props.log.created_at).toLocaleString() }}
+
+
+
+
+
Status: {{ props.log.status }}
+
To: {{ props.log.to_email }} {{ props.log.to_name ? '(' + props.log.to_name + ')' : '' }}
+
Subject: {{ props.log.subject }}
+
Template: {{ props.log.template?.name || '-' }}
+
Message ID: {{ props.log.message_id || '-' }}
+
Attempts: {{ props.log.attempt }}
+
Duration: {{ props.log.duration_ms ? props.log.duration_ms + ' ms' : '-' }}
+
Error: {{ props.log.error_message }}
+
+
+
+
Text
+
{{ props.log.body?.body_text || '' }}
+
+
+
+
+
+
+
+
diff --git a/resources/js/Pages/Admin/EmailTemplates/Edit.vue b/resources/js/Pages/Admin/EmailTemplates/Edit.vue
index 4772804..c5c0150 100644
--- a/resources/js/Pages/Admin/EmailTemplates/Edit.vue
+++ b/resources/js/Pages/Admin/EmailTemplates/Edit.vue
@@ -4,12 +4,7 @@ import { Head, Link, useForm, router, usePage } from "@inertiajs/vue3";
import { ref, watch, computed, onMounted, nextTick } from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faArrowLeft, faEye } from "@fortawesome/free-solid-svg-icons";
-// Ensure Quill is available before importing the wrapper component
-import Quill from "quill";
-if (typeof window !== "undefined" && !window.Quill) {
- // @ts-ignore
- window.Quill = Quill;
-}
+// Keep Quill CSS for nicer preview styling, but remove the Quill editor itself
import "quill/dist/quill.snow.css";
const props = defineProps({
@@ -44,9 +39,8 @@ const previewHtml = computed(() => {
return containsDocScaffold(html) ? extractBody(html) : html;
}
// Fallback to local content if server preview didn't provide HTML
- return sourceMode.value
- ? extractBody(form.html_template || "")
- : stripDocScaffold(form.html_template || "");
+ // We only use the advanced editor now, so just show the content when available
+ return extractBody(form.html_template || "");
});
const docsRaw = ref(props.template?.documents ? [...props.template.documents] : []);
const docs = computed(() =>
@@ -66,6 +60,12 @@ function updateLocalDoc(documentId, path, name = null, size = null) {
docsRaw.value.splice(idx, 1, next);
}
}
+async function removeLocalDoc(documentId) {
+ const idx = docsRaw.value.findIndex((d) => d.id === documentId);
+ if (idx !== -1) {
+ docsRaw.value.splice(idx, 1);
+ }
+}
function formatSize(bytes) {
if (!bytes && bytes !== 0) return "";
const kb = bytes / 1024;
@@ -97,6 +97,7 @@ const fetchPreview = () => {
// Always send full HTML so head/styles can be respected and inlined server-side
html: form.html_template,
text: form.text_template,
+ activity_id: sample.value.activity_id || undefined,
client_id: sample.value.client_id || undefined,
case_id: sample.value.case_id || undefined,
contract_id: sample.value.contract_id || undefined,
@@ -130,6 +131,7 @@ async function fetchFinalHtml() {
subject: form.subject_template,
html: form.html_template,
text: form.text_template,
+ activity_id: sample.value.activity_id || undefined,
client_id: sample.value.client_id || undefined,
case_id: sample.value.case_id || undefined,
contract_id: sample.value.contract_id || undefined,
@@ -186,62 +188,25 @@ onMounted(() => {
// Populate cascading selects immediately so the Client dropdown isn't empty
loadClients();
fetchPreview();
- // Mount Quill editor directly
- try {
- if (quillContainer.value) {
- // instantiate lazily to ensure DOM is ready
- const editor = new Quill(quillContainer.value, {
- theme: "snow",
- modules: quillModules.value,
- });
- quill.value = editor;
- if (form.html_template) {
- const bodyOnly = stripDocScaffold(form.html_template);
- if (bodyOnly) {
- // Ensure Quill properly converts HTML to Delta
- editor.clipboard.dangerouslyPasteHTML(0, bodyOnly, "api");
- } else {
- editor.setText(
- "(Body is empty. Switch to Source HTML to edit full document.)",
- "api"
- );
- }
- }
- editor.on("text-change", (_delta, _old, source) => {
- // Ignore programmatic changes; only persist user edits
- if (source !== "user") {
- return;
- }
- if (!sourceMode.value) {
- const bodyHtml = editor.root.innerHTML;
- form.html_template = containsDocScaffold(form.html_template)
- ? replaceBody(form.html_template, bodyHtml)
- : bodyHtml;
- }
- });
- }
- } catch (e) {
- console.error("Failed to mount Quill", e);
- }
- // Initialize iframe editor if advanced mode is on
- if (advancedMode.value) {
- initIframeEditor();
- }
+ // Advanced editor is the only mode
+ activeField.value = "html";
+ initIframeEditor();
});
// --- Variable insertion and sample entity selection ---
const subjectRef = ref(null);
-const quillContainer = ref(null);
const htmlSourceRef = ref(null);
const textRef = ref(null);
-const activeField = ref(null); // 'subject' | 'html' | 'text'
-const quill = ref(null);
-const sourceMode = ref(false); // toggle for HTML source editing
+const activeField = ref("html"); // default to HTML for variable inserts
+// Raw HTML editor toggle (full-document source)
+const rawMode = ref(false);
// Advanced full-document editor that renders styles from
-const advancedMode = ref(false);
+const advancedMode = ref(true);
const iframeRef = ref(null);
let iframeSyncing = false;
const selectedImageSrc = ref("");
+// Preview iframe ref (to render preview HTML with styles applied)
+const previewIframeRef = ref(null);
// Detect and handle full-document HTML so Quill doesn't wipe content
function containsDocScaffold(html) {
@@ -261,20 +226,9 @@ function extractBody(html) {
return m ? m[1] : html;
}
+// Retained for compatibility, but no longer used actively
function stripDocScaffold(html) {
- if (!html) return "";
- let out = html;
- // Prefer body content when present
- out = extractBody(out);
- // Remove comments, doctype, html/head blocks, meta/title, and styles (Quill can't keep these)
- out = out
- .replace(//gi, "")
- .replace(/<\/?html[^>]*>/gi, "")
- .replace(/[\s\S]*?<\/head>/gi, "")
- .replace(/<\/?meta[^>]*>/gi, "")
- .replace(/[\s\S]*?<\/title>/gi, "")
- .replace(/