Decision now support auto mailing

This commit is contained in:
Simon Pocrnjič
2025-10-12 00:20:03 +02:00
parent 1b615163be
commit 3ab1c05fcc
33 changed files with 1862 additions and 548 deletions
@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\EmailLog;
use App\Models\EmailTemplate;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class EmailLogController extends Controller
{
use AuthorizesRequests;
public function index(Request $request): Response
{
$this->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,
]);
}
}
@@ -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.
@@ -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 = '<p>This is a <strong>test email</strong> from profile <code>'.e($mailProfile->name).'</code> at '.e(now()->toDateTimeString()).'.</p>';
$text = 'This is a test email from profile "'.$mailProfile->name.'" at '.now()->toDateTimeString().'.';
// Build email
$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)
+32 -8
View File
@@ -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']),
+1 -1
View File
@@ -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 = [
+29 -32
View File
@@ -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']);
}
}
+16 -5
View File
@@ -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!');
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Jobs;
use App\Models\EmailLog;
use App\Models\EmailLogStatus;
use App\Services\EmailSender;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class SendEmailTemplateJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $emailLogId)
{
//
}
public function handle(): void
{
$log = EmailLog::query()->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;
}
}
}
+13 -1
View File
@@ -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');
}
}
+2
View File
@@ -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',
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
enum EmailLogStatus: string
{
case Queued = 'queued';
case Sending = 'sending';
case Sent = 'sent';
case Failed = 'failed';
case Bounced = 'bounced';
case Deferred = 'deferred';
}
class EmailLog extends Model
{
protected $fillable = [
'uuid',
'template_id',
'mail_profile_id',
'user_id',
'message_id',
'correlation_id',
'to_email',
'to_name',
'cc',
'bcc',
'from_email',
'from_name',
'reply_to',
'subject',
'body_html_hash',
'body_text_preview',
'attachments',
'embed_mode',
'status',
'error_code',
'error_message',
'transport',
'headers',
'attempt',
'duration_ms',
'client_id',
'client_case_id',
'contract_id',
'extra_context',
'queued_at',
'started_at',
'sent_at',
'failed_at',
'ip',
];
protected function casts(): array
{
return [
'status' => 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');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EmailLogBody extends Model
{
protected $fillable = [
'email_log_id',
'body_html',
'body_text',
'inline_css',
];
protected function casts(): array
{
return [
'inline_css' => 'boolean',
];
}
public function log(): BelongsTo
{
return $this->belongsTo(EmailLog::class, 'email_log_id');
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Services;
use App\Jobs\SendEmailTemplateJob;
use App\Models\Activity;
use App\Models\Client;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Decision;
use App\Models\Email;
use App\Models\EmailLog;
use App\Models\EmailLogStatus;
use App\Models\EmailTemplate;
class AutoMailDispatcher
{
public function __construct(public EmailTemplateRenderer $renderer) {}
/**
* Attempt to queue an auto mail for the given activity based on its decision/template.
* Returns array with either ['queued' => 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];
}
}
+192
View File
@@ -0,0 +1,192 @@
<?php
namespace App\Services;
use App\Models\EmailLog;
use App\Models\MailProfile;
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 EmailSender
{
public function __construct(public EmailTemplateRenderer $renderer) {}
/**
* Build and send the message described by the EmailLog. Returns ['message_id' => 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('#<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);
}
}
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];
}
}
+90 -4
View File
@@ -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'];
}