Sendex runs email newsletters in MODX Revolution: subscribers, a send queue, and a front-end form.
Requirements: PHP 7.4–8.4, MODX Revolution 2.8+ or 3.x (ExtJS manager).
Sendex keeps global sx* xPDO models (not MODX\Revolution\sx*). Verified on MODX 3.2.0-pl (2026-07-25): transport install, namespace assets_path, mgr menu (namespace + action), Phinx migrations, sx* class map, connector bootstrap, mgr processors, ExtJS UI.
Build/package notes:
_build/build.transport.phpregistersPKG_ASSETS_PATHand skipsbuild.model.phpon MODX 3 (xPDO 3 schema generator would rewrite maps tosendex\sx*).- Mgr menu uses legacy
modActionon MODX 2 andmodMenu.action+namespaceon MODX 3. core/components/sendex/bootstrap.php— shared init for connector, mgr, and cron (autoload + processor base aliases).sxModxCompat/sxUserProfile— MODX 2/3 boundary for mail, parser, registry, and user/profile placeholders.
- Newsletters and subscribers in the manager
- Add users one by one or from a MODX user group (active and unblocked accounts only)
- Guest subscribe with email confirmation; authenticated users subscribe directly
- Guest rows with the same email merge onto a new/activated
modUser(no second subscriber row) - Queue letters; send one, send all, or flush via cron
- Export subscriber emails
- English and Russian lexicons
Install the transport package through Package Management, or build one from _build/.
Schema changes ship as Phinx migrations (same pattern as MiniShop3):
- Config:
core/components/sendex/phinx.php - Migrations:
core/components/sendex/migrations/ - Metadata table:
{table_prefix}sendex_migrations
Current upgrade chain:
20260724120000_initial_schema.php20260724120100_subscriber_unique_email.php20260724130000_innodb_queue_subscriber_link.php20260725123000_purge_orphan_queue_rows.php20260725170000_subscriber_user_id_index.php20260725190000_add_queue_claim_fields.php20260725191000_convert_sendex_tables_to_utf8mb4.php20260725192000_normalize_subscriber_email_collation.php
Before building the transport package, install runtime deps into the component:
cd core/components/sendex
composer install --no-dev
cd ../../..
php _build/build.transport.php
On install/upgrade the migrations resolver runs phinx migrate. CLI on a live site:
cd core/components/sendex
composer install --no-dev
vendor/bin/phinx migrate -c phinx.php
Root composer.json remains for PHPUnit/phpcs only.
[[!Sendex? &id=`1`]]
| Property | Default | Purpose |
|---|---|---|
id | — | Newsletter ID |
showInactive | false | Show the form when the newsletter is disabled |
msgClass | active | CSS class for [[+class]] |
tplSubscribeAuth | tpl.Sendex.subscribe.auth | Chunk for logged-in users |
tplSubscribeGuest | tpl.Sendex.subscribe.guest | Chunk for guests |
tplUnsubscribe | tpl.Sendex.unsubscribe | Unsubscribe chunk |
tplActivate | tpl.Sendex.activate | Confirmation email chunk |
confirmEmail | system sendex_confirm_email (default 1) | Guest flow: 1 = confirm link by email; 0 = subscribe immediately |
loadJs | 1 | Register assets/components/sendex/js/web/sendex.js for AJAX forms |
widgetKey | (empty) | Optional key when several [[!Sendex]] widgets share one page; must match hidden sendex_widget_key in the form |
When confirmEmail is off, guest addresses are saved without a confirmation message. Typos and spam signups are harder to catch; use only when you accept that tradeoff.
Several widgets on one page need distinct &widgetKey= values (for example instant vs confirm) so AJAX POST is handled only by the matching snippet instance. Email confirm/unsubscribe links omit sendex_widget_key; keep one snippet on the landing page without widgetKey to handle those URLs.
Default chunks include data-sendex-* hooks; the snippet registers sendex.js, which submits forms via fetch and swaps the widget without a full page reload. The server answers with JSON {success, message, html}.
Minimal page markup:
[[!Sendex? &id=`1`]]Guest chunk structure (for custom templates):
<divclass="sendex-widget" data-sendex-widget><pclass="sendex-message [[+class]]" data-sendex-message><b>[[+message]]</b></p><formaction="" method="post" data-sendex-form><inputtype="hidden" name="sx_action" value="subscribe"><inputtype="email" name="email" required><buttontype="submit">Subscribe</button></form></div>Set &loadJs=0 if you ship your own JS but keep the same JSON contract (X-Requested-With: XMLHttpRequest or ajax=1).
Policy: merge, not block.
- Anonymous confirm /
subscribe()resolvesmodUserby profile email and storesuser_id(see#54). - Unique key
(newsletter_id, email)prevents a guest+user duplicate row. - If a guest subscribed first and the account is created later, the Sendex plugin merges on
OnUserActivateandOnUserSave: guest rows with that email getuser_idset. Merge does not run onOnBeforeUserActivate(cancelled activation must not attach guests). Reinstall/upgrade the package so the plugin events are registered.
Logged-in isSubscribed($userId) also matches a still-guest row by profile email, so the form shows unsubscribe without waiting for merge.
The page must call [[!Sendex? &id=...]] (any newsletter id is fine). Query params:
| Param | Required | Meaning |
|---|---|---|
sx_action | yes | unsubscribe |
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 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".
Process the queue from the site root (or adjust the path):
php core/components/sendex/cron/send.php
Set the batch size with the sendex_queue_limit system setting (default 100).
composer install
composer test
composer test:coverage # needs phpdbg
Unit tests use lightweight MODX/xPDO stubs (no MODX install). Coverage includes subscription/confirm flows, queue lifecycle events, queue claim, group subscribe, ACL contracts, frontend snippet contracts, and mail header sanitization. CI runs php -l, PHPUnit, and PHPCS on PHP 7.4–8.4; one PHP 8.2 job publishes Clover coverage as an artifact. Remaining integration gaps are tracked in issue #103.
Event names are registered in the transport package (BUILD_EVENT_UPDATE); reinstall or upgrade so they appear under System Events. invokeEvent by name works even before that.
| Event | When | Cancel |
|---|---|---|
sxOnBeforeSubscribe | Before creating a subscriber | Yes |
sxOnSubscribe | After a successful save | No |
sxOnBeforeUnsubscribe | Before removing a subscriber | Yes |
sxOnUnsubscribe | After a successful remove | No |
Params: newsletter, newsletter_id, user_id, email, subscriber, source (snippet|ajax|confirm|mgr|guest). Unsubscribe also passes code.
| Event | When | Cancel |
|---|---|---|
sxOnBeforeAddQueues | Before building queue rows | Yes (abort batch) |
sxOnAddQueues | After rows created | No |
sxOnBeforeQueueSend | Before sending one row (after claim) | Yes (skip, no requeue); may mutate message |
sxOnQueueSend | After successful send | No |
sxOnQueueSendFailed | After mail failure + requeue | No |
sxOnQueueFlushComplete | After flush batch | No |
| MODX event | Behavior |
|---|---|
OnManagerPageInit | Mgr CSS |
OnUserActivate / OnUserSave | Merge guest rows onto user by email |
OnBeforeUserActivate | Not used (cancelled activation must not merge) |
Cancel a Before event with $modx->event->output('error message');. Already-subscribed / missing or mismatched code paths are no-ops and do not fire subscribe events.
GPL-2.0. See LICENSE.