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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ The page must call `[[!Sendex? &id=...]]` (any newsletter id is fine). Query par
| `code` | yes | `sxSubscriber.code` |
| `newsletter_id` | no | Same as newsletter id; avoids confusion with MODX resource `id`. Snippet resolves the owner newsletter from `code` if the snippet `&id` differs. |

Default letter template links to `site_start` with `sx_action`, `newsletter_id`, and `code`.
Default letter template uses `[[+unsubscribe_url]]` (built at send time via `makeUrl` on `sendex_unsubscribe_page` or `site_start`). Do **not** nest `[[++site_start]]` inside `[[~…]]` — that becomes `[[~[[57]]]]` and logs "Bad link tag".

## Cron

Expand Down
1 change: 1 addition & 0 deletions core/components/sendex/docs/changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [2.0.1-pl] - 2026-07-29

### Fixed
- Email templates: nested `[[~[[++site_start]]]]` unsubscribe links became `[[~[[57]]]]` and logged "Bad link tag". Queue body render now provides `[[+unsubscribe_url]]`, flattens residual nested `[[~[[N]]]]` before parse, and documents `sendex_unsubscribe_page` (fallback: `site_start`).
- [#119] Mgr row-action icon buttons (edit/disable/send/remove) did nothing when the click hit the inner `<i>`: shared `SelectionMixin.onClick` now finds the button via `getTarget('button')`, resolves the row via `findRowIndex`, and reads the action from `data-action`.
- [#114] Mgr newsletter create appeared to hang on «Загружается…» and the grid stayed empty after reload (row was saved; duplicate-name error on retry). Newsletter `getlist` no longer uses JOIN/subquery SQL (subscriber count and template name are added in `prepareRow`); grid refresh after save is deferred so the create window can close first; `getlist`/`get` accept `view_sendex` as well as `view_document`; create `beforeSet()` returns strict `true` for MODX 3. Image column renderer uses `Sendex.utils.escapeHtmlAttr` (ExtJS does not keep grid scope for column renderers).
- [#111] Mgr row-action and menu icons no longer force `font-family: "Font Awesome 5 Free"` (Sendex does not load FA5); icons inherit the mgr icon font on MODX 2.3+/3.x or bundled FA4 on older MODX.
Expand Down
16 changes: 8 additions & 8 deletions core/components/sendex/elements/templates/template.sendex.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@
<hr>

<h1>Link for unsubscribe</h1>
Link must lead to a page that calls the Sendex snippet. Required query params:
<code>sx_action=unsubscribe</code>, <code>code</code> (subscriber code). Optional:
<code>newsletter_id</code> (same as <code>[[+newsletter.id]]</code>; the snippet also resolves the newsletter from <code>code</code> if the snippet <code>&id</code> differs).
<br/>
<pre>&#91;&#91;~id_of_resource?scheme=`full`&sx_action=`unsubscribe`&newsletter_id=`&#91;&#91;+newsletter.id&#93;&#93;`&code=`&#91;&#91;+subscriber.code&#93;&#93;`&#93;&#93;</pre>
<p>Prefer the ready-made placeholder (built in PHP — avoids nested link tags):</p>
<pre>&#91;&#91;+unsubscribe_url&#93;&#93;</pre>
<p>Example:</p>
<a href="[[+unsubscribe_url]]">Unsubscribe from this newsletter</a>

<br/><br/>
For example (works on site_start even when the snippet &id is another newsletter):<br/>
<a href="[[~[[++site_start]]?scheme=`full`&sx_action=`unsubscribe`&newsletter_id=`[[+newsletter.id]]`&code=`[[+subscriber.code]]`]]">Unsubscribe from this newsletter</a>
<p>If you build the URL yourself, use a numeric resource id (not <code>[[++site_start]]</code> inside <code>[[~…]]</code>).
Required query params: <code>sx_action=unsubscribe</code>, <code>code</code>. Optional: <code>newsletter_id</code>.</p>
<pre>&#91;&#91;~id_of_resource?scheme=`full`&sx_action=`unsubscribe`&newsletter_id=`&#91;&#91;+newsletter.id&#93;&#93;`&code=`&#91;&#91;+subscriber.code&#93;&#93;`&#93;&#93;</pre>
<p>Optional system setting <code>sendex_unsubscribe_page</code> overrides <code>site_start</code> for <code>[[+unsubscribe_url]]</code>.</p>
64 changes: 62 additions & 2 deletions core/components/sendex/model/sendex/sxqueuebodyrenderer.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ public static function render($xpdo, $newsletter, $subscriber)
}

$scriptProperties = array(
'newsletter' => $newsletter->toArray(),
'subscriber' => $subscriber->toArray(),
'newsletter' => $newsletter->toArray(),
'subscriber' => $subscriber->toArray(),
'unsubscribe_url' => self::buildUnsubscribeUrl($xpdo, $newsletter, $subscriber),
);

$userId = (int) $subscriber->get('user_id');
Expand All @@ -82,6 +83,9 @@ public static function render($xpdo, $newsletter, $subscriber)
$template->_output = '';
$body = $template->process($scriptProperties);

// Nested [[~[[++site_start]]]] becomes [[~[[57]]]] after ++ expands — invalid link tag.
$body = self::flattenNestedResourceLinks($body, (int) $xpdo->getOption('site_start'));

/** @var modParser|null $parser */
$parser = sxModxCompat::getParser($xpdo);
if ($parser && $parser instanceof modParser) {
Expand All @@ -92,6 +96,62 @@ public static function render($xpdo, $newsletter, $subscriber)
return $body;
}

/**
* Absolute unsubscribe URL for email templates (avoids nested [[~[[++site_start]]]]).
*
* @param object $xpdo
* @param object $newsletter
* @param object $subscriber
* @return string
*/
public static function buildUnsubscribeUrl($xpdo, $newsletter, $subscriber)
{
$resourceId = (int) $xpdo->getOption('sendex_unsubscribe_page', null, 0);
if ($resourceId <= 0) {
$resourceId = (int) $xpdo->getOption('site_start');
}
if ($resourceId <= 0 || !method_exists($xpdo, 'makeUrl')) {
return '';
}

$params = array(
'sx_action' => 'unsubscribe',
'newsletter_id' => (int) $newsletter->get('id'),
'code' => (string) $subscriber->get('code'),
);

$url = $xpdo->makeUrl($resourceId, '', $params, 'full');

return is_string($url) ? $url : '';
}

/**
* Rewrite [[~[[++site_start]]…]] / residual [[~[[123]]…]] into [[~123…]].
*
* @param string $body
* @param int $siteStart
* @return string
*/
public static function flattenNestedResourceLinks($body, $siteStart)
{
$siteStart = (int) $siteStart;
if ($siteStart <= 0 || !is_string($body) || $body === '') {
return $body;
}

$body = preg_replace(
'/\[\[~\s*\[\[\+\+site_start\]\]/',
'[[~' . $siteStart,
$body
);

return preg_replace(
'/\[\[~\s*\[\[(\d+)\]\]/',
'[[~$1',
$body
);
}

/**
* @param object $xpdo
* @param object $queue sxQueue-like
Expand Down
23 changes: 23 additions & 0 deletions tests/Stubs/FakeModX.php
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,29 @@ public function getTableName($class)
return $class;
}

/**
* @param int|string $id
* @param string $context
* @param array|string $args
* @param mixed $scheme
* @return string
*/
public function makeUrl($id, $context = '', $args = array(), $scheme = -1)
{
if (is_array($args)) {
$query = http_build_query($args);
} else {
$query = (string) $args;
}

$url = 'https://example.com/index.php?id=' . (int) $id;
if ($query !== '') {
$url .= '&' . $query;
}

return $url;
}

/**
* @return FakePdoConnection
*/
Expand Down
59 changes: 59 additions & 0 deletions tests/Unit/QueueBodyRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,63 @@ public function testDeliverMailRendersCompactBodyAtSendTime()
$this->assertTrue(sxQueueSender::deliverMail($queue));
$this->assertSame('Body for send@example.com', $mail->sets[modMail::MAIL_BODY]);
}

public function testFlattenNestedResourceLinksRewritesSiteStartAndNumericNesting()
{
$nested = '<a href="[[~[[++site_start]]?scheme=`full`&sx_action=`unsubscribe`]]">x</a>';
$this->assertSame(
'<a href="[[~57?scheme=`full`&sx_action=`unsubscribe`]]">x</a>',
sxQueueBodyRenderer::flattenNestedResourceLinks($nested, 57)
);

$residual = '[[~[[57]]?code=`abc`]]';
$this->assertSame(
'[[~57?code=`abc`]]',
sxQueueBodyRenderer::flattenNestedResourceLinks($residual, 57)
);
}

public function testBuildUnsubscribeUrlUsesSiteStartAndParams()
{
$this->modx->options['site_start'] = 57;

$newsletter = new TestableNewsletter($this->modx);
$newsletter->set('id', 1);

$subscriber = new sxSubscriber($this->modx);
$subscriber->fromArray(array(
'id' => 2,
'newsletter_id' => 1,
'code' => 'deadbeef',
'email' => 'a@example.com',
));

$url = sxQueueBodyRenderer::buildUnsubscribeUrl($this->modx, $newsletter, $subscriber);

$this->assertStringContainsString('id=57', $url);
$this->assertStringContainsString('sx_action=unsubscribe', $url);
$this->assertStringContainsString('newsletter_id=1', $url);
$this->assertStringContainsString('code=deadbeef', $url);
}

public function testBuildUnsubscribeUrlPrefersSendexUnsubscribePage()
{
$this->modx->options['site_start'] = 57;
$this->modx->options['sendex_unsubscribe_page'] = 12;

$newsletter = new TestableNewsletter($this->modx);
$newsletter->set('id', 3);

$subscriber = new sxSubscriber($this->modx);
$subscriber->fromArray(array(
'id' => 4,
'newsletter_id' => 3,
'code' => 'c0de',
'email' => 'b@example.com',
));

$url = sxQueueBodyRenderer::buildUnsubscribeUrl($this->modx, $newsletter, $subscriber);
$this->assertStringContainsString('id=12', $url);
$this->assertStringNotContainsString('id=57', $url);
}
}
6 changes: 4 additions & 2 deletions tests/Unit/UnsubscribeResolveTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,11 @@ public function testTemplateIncludesNewsletterIdQueryParam()
dirname(__DIR__, 2) . '/core/components/sendex/elements/templates/template.sendex.tpl'
);

$this->assertStringContainsString('[[+unsubscribe_url]]', $template);
$this->assertStringContainsString('sx_action=`unsubscribe`', $template);
$this->assertStringContainsString('newsletter_id=`[[+newsletter.id]]`', $template);
$this->assertStringContainsString('code=`[[+subscriber.code]]`', $template);
$this->assertStringContainsString('newsletter_id=`&#91;&#91;+newsletter.id&#93;&#93;`', $template);
$this->assertStringContainsString('code=`&#91;&#91;+subscriber.code&#93;&#93;`', $template);
$this->assertStringNotContainsString('[[~[[++site_start]]', $template);
}

public function testSnippetResolvesByCodeBeforeUnsubscribe()
Expand Down
Loading