Smart Delay (Send-Time Optimization) for Mautic 7+
A production-ready Mautic 7 plugin that adds a Smart Delay campaign action, a GrapesJS A/B subject-line block, and a daily engagement analysis CLI command. When a contact reaches the Smart Delay node, the plugin looks up their historical peak engagement hour and defers the rest of the campaign until that hour — so emails and follow-ups land when the contact is most likely to interact.
Current version: 1.0.0 · License: MIT · PHP 8.1+ · Mautic 7.x
- Features
- Requirements
- Installation
- Campaign usage
- How the algorithm works
- GrapesJS A/B Subject block
- Daily batch analysis (CLI)
- Optional summary table
- Performance & production tips
- Architecture
- Testing & CI
- Troubleshooting
- Changelog
- Support the project
- License & author
| Feature | Description |
|---|---|
| Smart Delay campaign action | Defers campaign execution to the contact’s historically most engaged hour (UTC) |
| Configurable form | Fallback hour (default 09:00 UTC) and minimum-interactions hint |
| Live query fallback | Works out of the box against audit_log; no extra schema required |
| Batch analyser CLI | mautic:smartdelay:analyze pre-computes optimal hours for high-volume installs |
| GrapesJS A/B Subject block | Alternative subject-line variant block for email-html / email-mjml builders |
| Asset auto-injection | AssetSubscriber loads the GrapesJS script into the admin UI |
| Mautic 7 / Symfony 7 | Autowiring, services.php, Extension class — no legacy service arrays |
| PHPUnit 11.5 suite | Executor, subscriber, form type, command, bundle, asset subscriber |
| GitHub Actions | Matrix: PHP 8.2 / 8.5 / 8.6 |
- Mautic 7.x (tested against the 7.x Symfony 7 stack)
- PHP 8.1+ (CI covers 8.2, 8.5, 8.6)
- MySQL / MariaDB (or compatible) with the standard Mautic
audit_logtable - Write access to
plugins/and ability to clear the Symfony cache
Copy the repository contents into Mautic’s plugins directory under the exact folder name expected by Composer’s extra.mautic-plugin-name:
plugins/
└── MauticSmartDelayBundle/
├── Assets/
├── Command/
├── Config/
├── DependencyInjection/
├── EventListener/
├── Execution/
├── Form/
├── Translations/
├── MauticSmartDelayBundle.php
├── composer.json
└── …
Alternatively, clone directly:
cd /path/to/mautic/plugins
git clone https://github.com/wieslawgolec/plugin-smart-delay.git MauticSmartDelayBundle- Log in to Mautic admin → Settings → Plugins
- Click Install/Upgrade Plugins
- Locate Smart Delay Bundle for Mautic 7+ and install/publish it
php bin/console cache:clear
# or, if you prefer a hard clear:
rm -rf var/cache/*
php bin/console cache:warmupphp bin/console list mautic:smartdelay
# Expected: mautic:smartdelay:analyze- Open Campaigns → New / Edit → Builder.
- Drag a new Action onto the canvas.
- Choose Smart Delay (Send-Time Optimization).
- (Optional) set:
- Fallback hour (UTC) — used when the contact has no engagement history (default
09:00). - Minimum interactions — reserved for future filtering (default
3).
- Fallback hour (UTC) — used when the contact has no engagement history (default
- Connect any subsequent actions (e.g. Send Email, webhook, tag update).
When a contact reaches the Smart Delay node during mautic:campaigns:trigger:
- The executor queries
audit_logfor that contact’s peak interaction hour. - It computes the next occurrence of that hour in UTC.
- It calls
$event->deferExecution($targetDateTime)so Mautic moves the log entry into the scheduled/background queue. - The campaign resumes at the deferred time and continues with the next connected actions.
Tip: Place Smart Delay immediately before the action you want time-optimised (usually Send Email). Multiple Smart Delay nodes in one campaign are supported.
┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
│ Contact reaches │────▶│ Query audit_log for │────▶│ Peak hour found? │
│ Smart Delay node│ │ HOUR(date_added) of │ │ │
└─────────────────┘ │ email + page events │ └──────────┬──────────┘
└──────────────────────┘ │
yes ┌───────┴───────┐ no
▼ ▼
Use peak hour Use fallback
(0–23) (default 9)
│ │
└───────┬───────┘
▼
target = today at hour:00 UTC
if target ≤ now → +1 day
│
▼
event->deferExecution(target)
event->setResult(true)
Details:
- Source table:
{prefix}audit_log - Filters:
lead_id = :idandbundle IN ('email', 'page') - Aggregation:
GROUP BY HOUR(date_added) ORDER BY COUNT(*) DESC LIMIT 1 - Timezone for scheduling: UTC (Mautic’s campaign scheduler converts to the system / contact timezone as usual)
- On any DB error or empty result → fallback hour (9 by default)
- Invalid hour values outside 0–23 are clamped back to the fallback
File: Assets/js/grapesjs-ab-subject.js
The script registers itself on window.MauticGrapesJsPlugins (the extension point introduced for custom GrapesJS plugins in Mautic). The AssetSubscriber injects the script into the admin UI automatically.
| Property | Value |
|---|---|
| Block id | smartdelay-ab-subject |
| Label | A/B Subject Line |
| Category | Smart Delay |
| Contexts | email-html, email-mjml |
| Trait | data-variant (default B) |
Editors can drop the block into an email and set the variant label. The block content is a lightweight HTML comment placeholder that can be expanded into a full subject-line A/B workflow in a future release.
For high-throughput installations (tens/hundreds of thousands of contacts per trigger cycle), a live multi-row audit_log query on every contact can become a bottleneck. The analyser pre-computes the top hour per contact once per day.
# Dry-run — compute only, print summary
php bin/console mautic:smartdelay:analyze --dry-run
# Full run — look back 90 days, batch writes of 500
php bin/console mautic:smartdelay:analyze --days=90 --batch-size=500
# Short options
php bin/console mautic:smartdelay:analyze -d 30 -b 200 --dry-run| Option | Short | Default | Description |
|---|---|---|---|
--days |
-d |
90 |
Look-back window in days |
--batch-size |
-b |
500 |
Contacts per write batch |
--dry-run |
off | Compute but do not persist |
Cron example (03:00 server time daily):
0 3 * * * php /path/to/mautic/bin/console mautic:smartdelay:analyze --no-interaction >> /var/log/mautic-smartdelay.log 2>&1If the optional summary table does not exist, the command still succeeds and reports that results were computed but not persisted. The live-query path in SmartDelayExecutor remains the default and requires no schema change.
Create this table if you want the analyser to persist results (future executor versions can read from it for O(1) lookups):
CREATE TABLE IF NOT EXISTS smartdelay_optimal_hours (
lead_id INT UNSIGNED NOT NULL PRIMARY KEY,
optimal_hour TINYINT UNSIGNED NOT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_updated (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;If you use a non-empty MAUTIC_TABLE_PREFIX, prefix the table name accordingly (e.g. mau_smartdelay_optimal_hours).
Recommended index on audit_log for the live query path:
-- Speeds up the per-contact peak-hour lookup
ALTER TABLE audit_log
ADD INDEX idx_smartdelay_lead_bundle_date (lead_id, bundle, date_added);(Adjust for your actual prefix and existing indexes.)
- Prefer the batch analyser on instances processing > ~50k campaign events/hour. Run it off-peak.
- Add the composite index above so the live path stays cheap for contacts without a pre-computed row.
- Monitor
mautic:campaigns:triggerduration after enabling Smart Delay; deferral itself is lightweight (one datetime write). - Timezone: scheduling is computed in UTC. If your contacts are concentrated in one region, consider aligning the fallback hour with that region’s morning.
- Cold contacts: contacts with no
email/pageaudit entries always get the fallback hour — that is intentional so the campaign never blocks. - Cache: always
cache:clearafter install/upgrade so the new event subscriber and command are discovered.
MauticSmartDelayBundle/
├── Assets/js/
│ └── grapesjs-ab-subject.js # GrapesJS plugin registration
├── Command/
│ └── AnalyzeEngagementCommand.php # mautic:smartdelay:analyze
├── Config/
│ ├── config.php # Plugin name, version, author
│ └── services.php # Autowire + autoconfigure
├── DependencyInjection/
│ └── MauticSmartDelayExtension.php # Loads services.php
├── EventListener/
│ ├── AssetSubscriber.php # Injects GrapesJS script
│ └── CampaignSubscriber.php # CAMPAIGN_ON_BUILD + trigger
├── Execution/
│ └── SmartDelayExecutor.php # Peak-hour math + deferExecution
├── Form/Type/
│ └── SmartDelayType.php # Fallback hour + min interactions
├── Translations/en_US/
│ └── messages.ini
├── tests/
│ ├── Stub/ # Mautic/Symfony stubs for CI
│ └── Unit/ # PHPUnit 11.5 tests
├── MauticSmartDelayBundle.php
├── composer.json
├── phpunit.xml.dist
└── .github/workflows/tests.yml
| Legacy approach | This plugin |
|---|---|
Service definitions in config.php arrays |
services.php + Extension + autowiring |
Raw callback on campaign action |
Dedicated eventName + subscriber method |
Blocking sleep() / busy-wait |
CampaignExecutionEvent::deferExecution() |
| Manual asset includes | AssetSubscriber on VIEW_INJECT_CUSTOM_ASSETS |
| Ad-hoc test scripts | PHPUnit 11.5 + GitHub Actions matrix |
CampaignSubscriber— listens toCampaignEvents::CAMPAIGN_ON_BUILDandmautic.smartdelay.on_campaign_trigger_action.SmartDelayExecutor— pure calculation + DB lookup; injectable and unit-tested in isolation.AnalyzeEngagementCommand— Symfony Console attribute command (#[AsCommand]).AssetSubscriber— ensures the GrapesJS block is available without theme edits.
cd plugins/MauticSmartDelayBundle # or a clone of this repo
composer install
vendor/bin/phpunit
# or
composer testTests use lightweight stubs under tests/Stub/ so they run without a full Mautic installation. Covered behaviour:
| Test class | What it verifies |
|---|---|
SmartDelayExecutorTest |
Peak hour, fallback, exception path, same-day / next-day scheduling, deferExecution + setResult |
CampaignSubscriberTest |
Action registration, context guard, end-to-end trigger |
AnalyzeEngagementCommandTest |
Dry-run, top-hour aggregation, query failure, CLI options |
SmartDelayTypeTest |
Form fields, block prefix, options |
BundleTest |
getPath(), namespace |
AssetSubscriberTest |
Subscribed events, instantiability |
.github/workflows/tests.yml runs on every push / PR to main or mautic7.x:
- OS:
ubuntu-latest - PHP matrix:
8.2,8.5,8.6 - PHPUnit:
^11.5 - fail-fast:
false(all matrix cells report independently)
| Symptom | Likely cause | Fix |
|---|---|---|
| Action not listed in Campaign Builder | Plugin not installed or cache stale | Re-run Install/Upgrade Plugins + cache:clear |
| Command not found | Cache / autoconfigure | php bin/console cache:clear then list mautic:smartdelay |
| Always falls back to 09:00 | No email/page rows in audit_log for that contact |
Expected for cold contacts; check tracking is enabled |
| Slow campaign trigger | Live query without index | Add the composite index; enable nightly analyze |
| GrapesJS block missing | Asset not loaded | Confirm AssetSubscriber is registered; hard-refresh browser |
| Table prefix mismatch | Custom MAUTIC_TABLE_PREFIX |
Prefix is read from the constant; ensure it matches your DB |
- Initial public release for Mautic 7 / Symfony 7
- Smart Delay campaign action with
deferExecution() - Configurable fallback hour form type
- GrapesJS A/B subject-line block + AssetSubscriber
mautic:smartdelay:analyzedaily batch command- PHPUnit 11.5 suite and GitHub Actions (PHP 8.2 / 8.5 / 8.6)
If this plugin saves you time, you can support development:
- GitHub Sponsors: github.com/sponsors/wieslawgolec
- Buy Me a Coffee: buymeacoffee.com/wieslawgolec
License: MIT
Author: Wieslaw Golec
Issues and pull requests are welcome at github.com/wieslawgolec/plugin-smart-delay.