Package and individual mail sender, new report, and other changes
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -90,7 +90,24 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
|
||||
// Ensure related names are available without extra queries
|
||||
$activity->loadMissing(['action', 'decision']);
|
||||
|
||||
// Ensure account is available on contract (needed for contract.account.* tokens)
|
||||
if ($contract && ! $contract->relationLoaded('account')) {
|
||||
$contract->load('account');
|
||||
}
|
||||
|
||||
// Resolve the sending profile once — used both for signature tokens and as the actual sender.
|
||||
// Prefer the profile explicitly requested via options, fall back to highest-priority active one.
|
||||
$mailProfile = isset($options['mail_profile_id'])
|
||||
? MailProfile::query()->find($options['mail_profile_id'])
|
||||
: null;
|
||||
$mailProfile ??= MailProfile::query()
|
||||
->where('active', true)
|
||||
->orderBy('priority')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
// Render content
|
||||
$bodyText = isset($options['body_text']) ? (string) $options['body_text'] : '';
|
||||
$rendered = $this->renderer->render([
|
||||
'subject' => (string) $template->subject_template,
|
||||
'html' => (string) $template->html_template,
|
||||
@@ -102,6 +119,8 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
|
||||
'person' => $person,
|
||||
'activity' => $activity,
|
||||
'extra' => [],
|
||||
'mail_profile' => $mailProfile,
|
||||
'body_text' => $bodyText,
|
||||
]);
|
||||
|
||||
// Create the log and body
|
||||
@@ -109,7 +128,7 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
|
||||
$log->fill([
|
||||
'uuid' => (string) \Str::uuid(),
|
||||
'template_id' => $template->id,
|
||||
'mail_profile_id' => optional(MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first())->id,
|
||||
'mail_profile_id' => $mailProfile?->id,
|
||||
'user_id' => auth()->id(),
|
||||
'to_email' => (string) ($recipients[0] ?? ''),
|
||||
'to_recipients' => $recipients,
|
||||
@@ -149,7 +168,7 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt
|
||||
|
||||
$log->body()->create([
|
||||
'body_html' => (string) ($rendered['html'] ?? ''),
|
||||
'body_text' => (string) ($rendered['text'] ?? ''),
|
||||
'body_text' => $bodyText !== '' ? $bodyText : (string) ($rendered['text'] ?? ''),
|
||||
'inline_css' => true,
|
||||
]);
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ public function getContracts(ClientCase $clientCase, ?int $segmentId = null): Co
|
||||
$query->forSegment($segmentId);
|
||||
}
|
||||
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
@@ -55,7 +54,7 @@ public function getActivities(
|
||||
int $perPage = 20
|
||||
): LengthAwarePaginator {
|
||||
$query = $clientCase->activities()
|
||||
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name'])
|
||||
->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name', 'emailLogs:id'])
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if (! empty($segmentId)) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Contact;
|
||||
|
||||
use App\Models\Email;
|
||||
use App\Models\Person\Person;
|
||||
|
||||
class EmailSelector
|
||||
{
|
||||
/**
|
||||
* Select the best email for a person following priority rules.
|
||||
* Priority:
|
||||
* 1) verified primary email that is active
|
||||
* 2) primary email that is active
|
||||
* 3) any active and valid email
|
||||
* 4) first active email
|
||||
*
|
||||
* Returns an array shape: ['email' => ?Email, 'reason' => ?string]
|
||||
*/
|
||||
public function selectForPerson(Person $person): array
|
||||
{
|
||||
$emails = Email::query()
|
||||
->where('person_id', $person->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('is_primary', 'desc')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
if ($emails->isEmpty()) {
|
||||
return ['email' => null, 'reason' => 'no_active_emails'];
|
||||
}
|
||||
|
||||
// 1) verified primary
|
||||
$email = $emails->first(fn (Email $e) => $e->is_primary && $e->verified_at !== null);
|
||||
if ($email) {
|
||||
return ['email' => $email, 'reason' => null];
|
||||
}
|
||||
|
||||
// 2) primary (any verification)
|
||||
$email = $emails->first(fn (Email $e) => $e->is_primary);
|
||||
if ($email) {
|
||||
return ['email' => $email, 'reason' => null];
|
||||
}
|
||||
|
||||
// 3) valid (any)
|
||||
$email = $emails->first(fn (Email $e) => $e->valid);
|
||||
if ($email) {
|
||||
return ['email' => $email, 'reason' => null];
|
||||
}
|
||||
|
||||
// 4) first active
|
||||
return ['email' => $emails->first(), 'reason' => null];
|
||||
}
|
||||
}
|
||||
@@ -30,17 +30,41 @@ public function render(array $template, array $ctx): array
|
||||
return preg_replace_callback('/{{\s*([a-zA-Z0-9_.]+)\s*}}/', function ($m) use ($map) {
|
||||
$key = $m[1];
|
||||
|
||||
// body_text is handled separately by applyBodyText(); preserve as literal
|
||||
if ($key === 'body_text') {
|
||||
return $m[0];
|
||||
}
|
||||
|
||||
return (string) data_get($map, $key, '');
|
||||
}, $input);
|
||||
};
|
||||
|
||||
$bodyText = isset($ctx['body_text']) ? (string) $ctx['body_text'] : '';
|
||||
|
||||
return [
|
||||
'subject' => $replacer($template['subject']) ?? '',
|
||||
'html' => $replacer($template['html'] ?? null) ?? null,
|
||||
'text' => $replacer($template['text'] ?? null) ?? null,
|
||||
'html' => $this->applyBodyText($replacer($template['html'] ?? null) ?? null, $bodyText, html: true),
|
||||
'text' => $this->applyBodyText($replacer($template['text'] ?? null) ?? null, $bodyText, html: false),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute the literal {{body_text}} placeholder with the user-supplied body text.
|
||||
* In HTML context the text is HTML-escaped and newlines are converted to <br>.
|
||||
* In plain-text context the raw value is used.
|
||||
*/
|
||||
public function applyBodyText(?string $content, string $bodyText, bool $html = true): ?string
|
||||
{
|
||||
if ($content === null) {
|
||||
return null;
|
||||
}
|
||||
$replacement = $html
|
||||
? nl2br(htmlspecialchars($bodyText, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'))
|
||||
: $bodyText;
|
||||
|
||||
return preg_replace('/{{\s*body_text\s*}}/', $replacement, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, activity?:Activity, extra?:array} $ctx
|
||||
*/
|
||||
@@ -145,8 +169,11 @@ protected function buildMap(array $ctx): array
|
||||
'id' => data_get($co, 'id'),
|
||||
'uuid' => data_get($co, 'uuid'),
|
||||
'reference' => data_get($co, 'reference'),
|
||||
// Format amounts in EU style for emails
|
||||
'amount' => $formatMoneyEu(data_get($co, 'amount')),
|
||||
// Account amounts — sourced from the related Account model
|
||||
'account' => [
|
||||
'balance_amount' => $formatMoneyEu(data_get($co, 'account.balance_amount')),
|
||||
'initial_amount' => $formatMoneyEu(data_get($co, 'account.initial_amount')),
|
||||
],
|
||||
];
|
||||
$meta = data_get($co, 'meta');
|
||||
if (is_array($meta)) {
|
||||
@@ -172,6 +199,12 @@ protected function buildMap(array $ctx): array
|
||||
if (! empty($ctx['extra']) && is_array($ctx['extra'])) {
|
||||
$out['extra'] = $ctx['extra'];
|
||||
}
|
||||
if (isset($ctx['mail_profile'])) {
|
||||
$mp = $ctx['mail_profile'];
|
||||
$out['profile'] = [
|
||||
'signature' => is_array($mp->signature) ? $mp->signature : [],
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user