Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions core/components/minishop3/elements/chunks/ms3_email.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -59,28 +59,28 @@
<td style="{$style.th}">
{if $product.thumb?}
<img src="{$site_url}{$product.thumb}"
alt="{$product.pagetitle}"
title="{$product.pagetitle}"
alt="{$product.pagetitle | e}"
title="{$product.pagetitle | e}"
width="120" height="90"/>
{else}
<img src="{$site_url}{$assets_url}components/minishop3/img/web/ms3_small@2x.png"
alt="{$product.pagetitle}"
title="{$product.pagetitle}"
alt="{$product.pagetitle | e}"
title="{$product.pagetitle | e}"
width="120" height="90"/>
{/if}
</td>
<td style="{$style.th}">
{if $product.product_id?}
<a href="{$product.product_id | url : ['scheme' => 'full']}"
style="{$style.a}">
{$product.name}
{$product.name | e}
</a>
{else}
{$product.name}
{$product.name | e}
{/if}
{if $product.options?}
<div class="small">
{$product.options | join : '; '}
{$product.options | join : '; ' | e}
</div>
{/if}
</td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
{foreach ['first_name','last_name','phone','email'] as $field}
{if $address[$field]}
<tr>
<td>{('ms3_frontend_' ~ $field) | lexicon}: {$address[$field]} </td>
<td>{('ms3_frontend_' ~ $field) | lexicon}: {$address[$field] | e} </td>
</tr>
{/if}
{/foreach}
Expand All @@ -27,7 +27,7 @@
{foreach ['index','region','city', 'street', 'building', 'entrance','floor', 'room'] as $field}
{if $address[$field]}
<tr>
<td>{('ms3_frontend_' ~ $field) | lexicon}: {$address[$field]} </td>
<td>{('ms3_frontend_' ~ $field) | lexicon}: {$address[$field] | e} </td>
</tr>
{/if}
{/foreach}
Expand All @@ -41,7 +41,7 @@
<h3 style="{$style.h}{$style.h3} margin-bottom: 20px;">{'ms3_frontend_comment' | lexicon}</h3>
<table style="width:95%;margin:auto;">
<tr>
<td>{$address.comment}</td>
<td>{$address.comment | e}</td>
</tr>
</table>
</td>
Expand Down
171 changes: 161 additions & 10 deletions core/components/minishop3/src/Notifications/Channels/EmailChannel.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,31 @@
*/
class EmailChannel implements ChannelInterface
{
/**
* Открывающие последовательности тегов и метки для их подмены (#888).
*
* Fenom подставляет значения плейсхолдеров в вывод как есть, а идущий
* следом парсер читает готовое тело уже как шаблон. С установленным
* pdoTools `$modx->getParser()` — это Fenom, а не разбор MODX-тегов,
* поэтому из комментария покупателя исполнялись и `[[++setting]]`, и
* `{$_modx->runSnippet(...)}`.
*
* Данные уходят в рендер с подменёнными открывающими последовательностями
* и возвращаются к исходному виду уже после парсера: шаблон по-прежнему
* может пользоваться и MODX-тегами, и Fenom, а текст покупателя остаётся
* текстом.
*
* Метки — цифры, чтобы модификатор вроде `|upper` не изменил маркер и
* разворачивание не прошло мимо.
*/
private const TAG_OPENERS = [
'[[' => '1',
'{' => '2',
];

/** Метка замаскированного плоского плейсхолдера темы. */
private const FLAT_MARKER = '3';

protected modX $modx;

public function __construct(modX $modx)
Expand Down Expand Up @@ -121,6 +146,7 @@ protected function buildBody(EmailMessage $message, Notification $notification):
$this->modx->lexicon->load('minishop3:cart');

$body = $message->getBody();
$nonce = null;

// If chunk is specified, render it
if ($chunkName = $message->getChunk()) {
Expand All @@ -129,6 +155,10 @@ protected function buildBody(EmailMessage $message, Notification $notification):
$message->getPlaceholders()
);

// Данные покупателя не должны дожить до парсера в виде тегов.
$nonce = $this->tagNonce();
$placeholders = $this->neutralizeTags($placeholders, $nonce);

// Use output buffering to capture any stray output from Fenom/pdoTools
ob_start();

Expand All @@ -149,7 +179,7 @@ protected function buildBody(EmailMessage $message, Notification $notification):
$this->modx->getParser()->processElementTags('', $body, true, false, '[[', ']]', [], 10);
$this->modx->getParser()->processElementTags('', $body, true, true, '[[', ']]', [], 10);

return $body;
return $nonce === null ? $body : $this->restoreTags($body, $nonce);
}

/**
Expand All @@ -172,6 +202,21 @@ protected function buildSubject(EmailMessage $message, Notification $notificatio
$message->getPlaceholders()
);

$nonce = $this->tagNonce();

// Плоские {key} прячем в самом шаблоне. Fenom такой синтаксис своим не
// считает, но спотыкается на нём при компиляции: тема вида
// «Заказ {num} / {$status_name}» валила разбор, а pdoTools на ошибке
// отдаёт пустую строку — тема уходила пустой.
[$subject, $flatValues] = $this->maskFlatPlaceholders($subject, $placeholders, $nonce);

// Значения тоже под защиту: getChunk() после Fenom сам подставляет
// [[+key]] и прогоняет остаток через разбор MODX-тегов
// (pdotools CoreTools::getChunk), поэтому данные покупателя, вставленные
// Fenom через {$order_comment} или {$address.first_name}, доезжали до
// парсера как шаблон.
$placeholders = $this->neutralizeTags($placeholders, $nonce);

// Use output buffering to capture any stray output from Fenom/pdoTools
ob_start();

Expand All @@ -181,22 +226,128 @@ protected function buildSubject(EmailMessage $message, Notification $notificatio
$pdoFetch = $this->modx->services->get(Fetch::class);
$subject = $pdoFetch->getChunk('@INLINE ' . $subject, $placeholders);
} else {
// Fallback: simple placeholder replacement + MODX parser
foreach ($placeholders as $key => $value) {
if (is_scalar($value)) {
$subject = str_replace("{{$key}}", (string) $value, $subject);
$subject = str_replace("{$key}", (string) $value, $subject);
}
}
// Process MODX tags (lexicon, settings, etc.)
// Process MODX tags (lexicon, settings, etc.) — до подстановки данных.
$this->modx->getParser()->processElementTags('', $subject, true, false, '[[', ']]', [], 10);
$this->modx->getParser()->processElementTags('', $subject, true, true, '[[', ']]', [], 10);
}

// Discard any stray output
ob_end_clean();

return $subject;
// Сначала значения плоских ключей, потом открывающие последовательности.
// Обратный порядок рвал маркер: он заканчивается меткой, и стоящая
// сразу за ним цифра 1 или 2 складывалась с ней в то, что ищет
// restoreTags() — тема «Заказ {num}1 шт» уезжала с мусором вместо
// номера. После разворачивания висячей метки в строке не остаётся:
// нейтрализованные значения держат её только в составе пары, а сырое
// значение плоского ключа 18-значную метку содержать не может.
//
// Оба шага идут после парсеров, поэтому шаблоном значение не станет.
// strtr не проходит по только что вставленному тексту, так что
// значение с {другим_ключом} внутри остаётся как есть.
if ($flatValues !== []) {
$subject = strtr($subject, $flatValues);
}

return $this->restoreTags($subject, $nonce);
}

/**
* Заменить в шаблоне темы плоские {key} на маркеры.
*
* Темы вида «Заказ {num}» держатся на этой подстановке: Fenom понимает
* только {$key}. Берём лишь скаляры и лишь точное совпадение {key} —
* без скобок подстановка затронула бы любое вхождение имени ключа.
*
* @param array<string, mixed> $placeholders
* @return array{0: string, 1: array<string, string>} шаблон и карта маркер => значение
*/
protected function maskFlatPlaceholders(string $subject, array $placeholders, string $nonce): array
{
if (!str_contains($subject, '{')) {
return [$subject, []];
}

$values = [];
$index = 0;

foreach ($placeholders as $key => $value) {
if (!is_scalar($value)) {
continue;
}

$tag = '{' . $key . '}';
if (!str_contains($subject, $tag)) {
continue;
}

$marker = $nonce . self::FLAT_MARKER . $index++ . $nonce;
$subject = str_replace($tag, $marker, $subject);
$values[$marker] = (string) $value;
}

return [$subject, $values];
}

/**
* Разовая метка для подмены открывающих последовательностей тегов.
*
* Случайная на каждое письмо: иначе покупатель мог бы вписать саму метку
* в комментарий и получить обратно `[[` или `{` после разворачивания.
*
* Только цифры — модификатор шаблона вроде `|upper` не должен её изменить,
* иначе разворачивание пройдёт мимо и читатель увидит маркер.
*
* От модификатора, который режет строку (`|truncate`), это не спасает:
* обрезанный маркер развернуть не выйдет, и в письме останутся цифры
* вместо `[[` или `{`. Косметика, и только если покупатель вписал в поле
* открывающую последовательность, — но знать об этом стоит.
*/
protected function tagNonce(): string
{
return '9' . random_int(10 ** 15, 10 ** 16 - 1) . '9';
}

/**
* Подменить открывающие последовательности тегов во всех строках данных.
*
* Рекурсивно, потому что плейсхолдеры письма — вложенные массивы (order,
* address, customer, products). Объекты не обходим: в плейсхолдеры письма
* они не попадают, всё приводится к массивам через toArray().
*/
protected function neutralizeTags(mixed $value, string $nonce): mixed
{
if (is_array($value)) {
return array_map(fn($item): mixed => $this->neutralizeTags($item, $nonce), $value);
}

if (!is_string($value) || $value === '') {
return $value;
}

$search = [];
$replace = [];
foreach (self::TAG_OPENERS as $opener => $label) {
$search[] = $opener;
$replace[] = $nonce . $label;
}

return str_replace($search, $replace, $value);
}

/**
* Вернуть подменённые последовательности на место — после всех парсеров.
*/
protected function restoreTags(string $text, string $nonce): string
{
$search = [];
$replace = [];
foreach (self::TAG_OPENERS as $opener => $label) {
$search[] = $nonce . $label;
$replace[] = $opener;
}

return str_replace($search, $replace, $text);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,10 @@ public function toEmail(string $recipientType): ?EmailMessage
return null;
}

// Process subject placeholders
$subject = $this->processSubject($subject ?? '');

// Тема уходит шаблоном как есть: плейсхолдеры в ней подставляет
// EmailChannel::buildSubject() — после парсеров, а не до них (#888).
return (new EmailMessage())
->subject($subject)
->subject($subject ?? '')
->chunk($template)
->with($this->getPlaceholders());
}
Expand Down Expand Up @@ -294,26 +293,6 @@ private function resolvePaymentLink(): ?string
return $this->paymentLink;
}

/**
* Process subject line with placeholders
*
* @param string $subject
* @return string
*/
protected function processSubject(string $subject): string
{
$pls = $this->getPlaceholders();

// Simple placeholder replacement
foreach ($pls as $key => $value) {
if (is_scalar($value)) {
$subject = str_replace("{{$key}}", (string) $value, $subject);
}
}

return $subject;
}

/**
* Render a template with placeholders
*
Expand Down
6 changes: 3 additions & 3 deletions core/components/minishop3/src/Utils/Utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,9 @@ public function pathinfo($path, $part = '')
*/
public function sendEmail(string $email, string $subject, string $body = ''): void
{
$this->modx->getParser()->processElementTags('', $body, true, false, '[[', ']]', [], 10);
$this->modx->getParser()->processElementTags('', $body, true, true, '[[', ']]', [], 10);

// Тело приходит собранным, и прогон парсера по нему исполнял теги из
// данных покупателя (#888). Вызывающий код сам решает, что разбирать,
// пока в строке ещё нет чужого текста.
$mail = new modPHPMailer($this->modx);
$mail->setHTML(true);

Expand Down
Loading
Loading