Decision now support auto mailing
This commit is contained in:
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user