From 27e8539d26b874ae298ee32818cba825d588b63c Mon Sep 17 00:00:00 2001
From: Matt Gros
Date: Fri, 22 May 2026 02:42:05 -0400
Subject: [PATCH 1/6] feat(newsletters): add schema (via activator), models,
renderer, themes (Wave 3 partial)
---
README.md | 22 +++
includes/Models/Newsletter/Newsletter.php | 42 ++++++
.../Models/Newsletter/NewsletterDelivery.php | 24 ++++
includes/Models/Newsletter/NewsletterList.php | 33 +++++
.../Newsletter/NewsletterListMember.php | 13 ++
.../Models/Newsletter/NewsletterTemplate.php | 19 +++
.../Newsletter/NewsletterRenderer.php | 136 ++++++++++++++++++
includes/class-activator.php | 125 ++++++++++++++++
templates/newsletter_themes/branded.php | 42 ++++++
templates/newsletter_themes/default.php | 34 +++++
10 files changed, 490 insertions(+)
create mode 100644 includes/Models/Newsletter/Newsletter.php
create mode 100644 includes/Models/Newsletter/NewsletterDelivery.php
create mode 100644 includes/Models/Newsletter/NewsletterList.php
create mode 100644 includes/Models/Newsletter/NewsletterListMember.php
create mode 100644 includes/Models/Newsletter/NewsletterTemplate.php
create mode 100644 includes/Services/Newsletter/NewsletterRenderer.php
create mode 100644 templates/newsletter_themes/branded.php
create mode 100644 templates/newsletter_themes/default.php
diff --git a/README.md b/README.md
index 7ada06a..28a8293 100644
--- a/README.md
+++ b/README.md
@@ -185,6 +185,28 @@ If needed, set `WP_TESTS_DIR` to your local WordPress tests library path before
- **[Escalated for WordPress](https://github.com/escalated-dev/escalated-wordpress)** — WordPress plugin (you are here)
- **[Shared Frontend](https://github.com/escalated-dev/escalated)** — Vue 3 + Inertia.js UI components
+## Newsletters (optional, partial port)
+
+Schema, models, and renderer for the admin-only newsletter broadcast feature. Off by default — flip `escalated_newsletters_enabled` option (or pass `1` through the standard Escalated settings UI) to turn it on. WP-special: uses WP options, WP-Cron, and WP capabilities (see CLAUDE.md / spec).
+
+```php
+// Plug in a Markdown renderer (Parsedown, league/commonmark, etc.)
+add_filter('escalated_newsletter_markdown_renderer', function ($_, $md) {
+ return Parsedown::instance()->text($md);
+}, 10, 2);
+```
+
+Custom themes go in `templates/newsletter_themes/.php` and receive `$subject`, `$body` (pre-rendered safe HTML), `$unsubscribe_url`, `$view_in_browser_url`, `$brand` (associative array).
+
+Ships:
+- 5 new tables created by `Escalated\Activator::create_newsletter_tables()` (auto-called by the activator)
+- `marketing_opt_out_at` column added to `escalated_contacts`
+- 5 model wrappers under `includes/Models/Newsletter/`
+- `includes/Services/Newsletter/NewsletterRenderer.php` — full renderer
+- Two starter themes in `templates/newsletter_themes/{default,branded}.php`
+
+Follow-up PR: WP-Cron tick for dispatcher, planner/tracker services, admin pages (custom or via the Inertia frontend), REST API endpoints for tracking + unsubscribe + view-in-browser, ESP webhook endpoints.
+
## License
MIT
diff --git a/includes/Models/Newsletter/Newsletter.php b/includes/Models/Newsletter/Newsletter.php
new file mode 100644
index 0000000..8bc4f72
--- /dev/null
+++ b/includes/Models/Newsletter/Newsletter.php
@@ -0,0 +1,42 @@
+get_row($wpdb->prepare("SELECT * FROM " . self::table() . " WHERE id = %d", $id)) ?: null;
+ }
+
+ public static function create(array $attrs): int
+ {
+ global $wpdb;
+ $now = current_time('mysql');
+ $wpdb->insert(
+ self::table(),
+ array_merge(['status' => 'draft', 'created_at' => $now, 'updated_at' => $now], $attrs)
+ );
+ return (int) $wpdb->insert_id;
+ }
+
+ public static function update(int $id, array $attrs): void
+ {
+ global $wpdb;
+ $wpdb->update(self::table(), array_merge($attrs, ['updated_at' => current_time('mysql')]), ['id' => $id]);
+ }
+}
diff --git a/includes/Models/Newsletter/NewsletterDelivery.php b/includes/Models/Newsletter/NewsletterDelivery.php
new file mode 100644
index 0000000..05058a0
--- /dev/null
+++ b/includes/Models/Newsletter/NewsletterDelivery.php
@@ -0,0 +1,24 @@
+get_row($wpdb->prepare(
+ "SELECT * FROM " . self::table() . " WHERE tracking_token = %s",
+ $token
+ )) ?: null;
+ }
+}
diff --git a/includes/Models/Newsletter/NewsletterList.php b/includes/Models/Newsletter/NewsletterList.php
new file mode 100644
index 0000000..9166a40
--- /dev/null
+++ b/includes/Models/Newsletter/NewsletterList.php
@@ -0,0 +1,33 @@
+get_row($wpdb->prepare("SELECT * FROM " . self::table() . " WHERE id = %d", $id)) ?: null;
+ }
+
+ public static function create(array $attrs): int
+ {
+ global $wpdb;
+ $now = current_time('mysql');
+ $wpdb->insert(
+ self::table(),
+ array_merge(['created_at' => $now, 'updated_at' => $now], $attrs)
+ );
+ return (int) $wpdb->insert_id;
+ }
+}
diff --git a/includes/Models/Newsletter/NewsletterListMember.php b/includes/Models/Newsletter/NewsletterListMember.php
new file mode 100644
index 0000000..dd9497a
--- /dev/null
+++ b/includes/Models/Newsletter/NewsletterListMember.php
@@ -0,0 +1,13 @@
+get_row($wpdb->prepare("SELECT * FROM " . self::table() . " WHERE id = %d", $id)) ?: null;
+ }
+}
diff --git a/includes/Services/Newsletter/NewsletterRenderer.php b/includes/Services/Newsletter/NewsletterRenderer.php
new file mode 100644
index 0000000..dc5a837
--- /dev/null
+++ b/includes/Services/Newsletter/NewsletterRenderer.php
@@ -0,0 +1,136 @@
+ theme wrap -> click rewrite -> pixel injection.
+ *
+ * Markdown is host-pluggable via the `escalated_newsletter_markdown_renderer`
+ * WordPress filter. The default fallback is a minimal escape+paragraph wrap.
+ */
+class NewsletterRenderer
+{
+ private const ALLOWED_SCHEMES = ['http', 'https', 'mailto', 'tel'];
+
+ public function render(object $delivery, object $newsletter, object $contact, ?object $template = null): string
+ {
+ $body_md = $newsletter->body_markdown ?? ($template->body_markdown ?? '');
+ $theme_slug = $newsletter->theme ?? ($template->theme ?? get_option('escalated_newsletter_default_theme', 'default'));
+
+ $body = $this->markdown_to_html($body_md);
+ $body = $this->resolve_merge_fields($body, $contact, $delivery);
+
+ $themed = $this->render_theme($theme_slug, [
+ 'subject' => $newsletter->subject ?? '',
+ 'body' => $body,
+ 'unsubscribe_url' => $this->unsubscribe_url($delivery),
+ 'view_in_browser_url' => $this->view_in_browser_url($delivery),
+ 'brand' => $this->brand(),
+ ]);
+
+ if (! get_option('escalated_newsletter_tracking_enabled', '1')) {
+ return $themed;
+ }
+
+ return $this->inject_pixel($this->rewrite_links($themed, $delivery), $delivery);
+ }
+
+ public function unsubscribe_url(object $delivery): string
+ {
+ return untrailingslashit(home_url()) . '/escalated/n/u/' . $delivery->tracking_token;
+ }
+
+ public function view_in_browser_url(object $delivery): string
+ {
+ return untrailingslashit(home_url()) . '/escalated/n/v/' . $delivery->tracking_token;
+ }
+
+ private function brand(): array
+ {
+ return [
+ 'name' => get_option('escalated_brand_name', get_bloginfo('name')),
+ 'accent' => get_option('escalated_brand_accent', '#2563eb'),
+ 'logo_url' => get_option('escalated_brand_logo_url', ''),
+ 'physical_address' => get_option('escalated_brand_physical_address', ''),
+ ];
+ }
+
+ private function markdown_to_html(string $md): string
+ {
+ $rendered = apply_filters('escalated_newsletter_markdown_renderer', null, $md);
+ if (is_string($rendered)) {
+ return $rendered;
+ }
+ $escaped = esc_html($md);
+ return '' . implode('
', preg_split('/\n{2,}/', $escaped)) . '
';
+ }
+
+ private function resolve_merge_fields(string $html, object $contact, object $delivery): string
+ {
+ return preg_replace_callback('/\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/', function ($m) use ($contact, $delivery) {
+ return esc_html($this->resolve_path(trim($m[1]), $contact, $delivery));
+ }, $html);
+ }
+
+ private function resolve_path(string $path, object $contact, object $delivery): string
+ {
+ $name = (string) ($contact->name ?? '');
+ switch ($path) {
+ case 'contact.name': return $name;
+ case 'contact.first_name': return explode(' ', $name)[0] ?? '';
+ case 'contact.email': return (string) ($contact->email ?? '');
+ case 'unsubscribe_url': return $this->unsubscribe_url($delivery);
+ case 'view_in_browser_url': return $this->view_in_browser_url($delivery);
+ }
+ if (strpos($path, 'contact.metadata.') === 0) {
+ $key = substr($path, strlen('contact.metadata.'));
+ $meta = json_decode($contact->metadata ?? '{}', true) ?: [];
+ return isset($meta[$key]) ? (string) $meta[$key] : '';
+ }
+ return '';
+ }
+
+ private function render_theme(string $slug, array $ctx): string
+ {
+ $themes_dir = apply_filters(
+ 'escalated_newsletter_themes_dir',
+ ESCALATED_PLUGIN_DIR . 'templates/newsletter_themes'
+ );
+ $path = $themes_dir . '/' . $slug . '.php';
+ if (! file_exists($path)) {
+ $path = $themes_dir . '/default.php';
+ }
+ extract($ctx, EXTR_OVERWRITE);
+ ob_start();
+ include $path;
+ return (string) ob_get_clean();
+ }
+
+ private function rewrite_links(string $html, object $delivery): string
+ {
+ $unsub = $this->unsubscribe_url($delivery);
+ $view = $this->view_in_browser_url($delivery);
+ return preg_replace_callback('#(]*\bhref=)("|\')(.*?)\2#i', function ($m) use ($delivery, $unsub, $view) {
+ $prefix = $m[1]; $quote = $m[2]; $href = $m[3];
+ if ($href === '' || strpos($href, '#') === 0) return $m[0];
+ $scheme = strtolower(parse_url($href, PHP_URL_SCHEME) ?: '');
+ if (! in_array($scheme, self::ALLOWED_SCHEMES, true)) return "{$prefix}{$quote}#{$quote}";
+ if (in_array($scheme, ['mailto', 'tel'], true)) return $m[0];
+ if (strpos($href, $unsub) === 0 || strpos($href, $view) === 0) return $m[0];
+ $encoded = rtrim(strtr(base64_encode($href), '+/', '-_'), '=');
+ $tracked = untrailingslashit(home_url()) . '/escalated/n/c/' . $delivery->tracking_token . '?u=' . $encoded;
+ return "{$prefix}{$quote}{$tracked}{$quote}";
+ }, $html);
+ }
+
+ private function inject_pixel(string $html, object $delivery): string
+ {
+ $url = untrailingslashit(home_url()) . '/escalated/n/o/' . $delivery->tracking_token . '.gif';
+ $pixel = '
';
+ if (strpos($html, '
+
+') !== false) {
+ return str_replace('', $pixel . '', $html);
+ }
+ return $html . $pixel;
+ }
+}
diff --git a/includes/class-activator.php b/includes/class-activator.php
index 811290f..61c4459 100644
--- a/includes/class-activator.php
+++ b/includes/class-activator.php
@@ -13,6 +13,7 @@ class Activator
public static function activate(): void
{
self::create_tables();
+ self::create_newsletter_tables();
self::seed_permissions();
self::create_roles();
self::add_admin_caps();
@@ -56,6 +57,7 @@ public static function maybe_upgrade(): void
}
self::create_tables();
+ self::create_newsletter_tables();
self::seed_permissions();
self::add_admin_caps();
self::insert_default_settings();
@@ -864,6 +866,129 @@ private static function insert_default_settings(): void
}
}
+ /**
+ * Optional newsletter system tables. Not registered to any specific
+ * activation flag — the data is harmless if unused. Behavior is gated by
+ * the `escalated_newsletters_enabled` option.
+ */
+ public static function create_newsletter_tables(): void
+ {
+ global $wpdb;
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+ $prefix = $wpdb->prefix . 'escalated_';
+ $charset_collate = $wpdb->get_charset_collate();
+
+ $sql = "CREATE TABLE {$prefix}newsletter_lists (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ name VARCHAR(255) NOT NULL,
+ description TEXT NULL,
+ kind VARCHAR(16) NOT NULL,
+ filter_json TEXT NULL,
+ created_by BIGINT UNSIGNED NULL,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL,
+ PRIMARY KEY (id),
+ KEY kind (kind),
+ KEY created_by (created_by)
+ ) $charset_collate;";
+ dbDelta($sql);
+
+ $sql = "CREATE TABLE {$prefix}newsletter_list_members (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ list_id BIGINT UNSIGNED NOT NULL,
+ contact_id BIGINT UNSIGNED NOT NULL,
+ added_at DATETIME NOT NULL,
+ added_by BIGINT UNSIGNED NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY list_contact (list_id, contact_id),
+ KEY contact_id (contact_id)
+ ) $charset_collate;";
+ dbDelta($sql);
+
+ $sql = "CREATE TABLE {$prefix}newsletter_templates (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ name VARCHAR(255) NOT NULL,
+ theme VARCHAR(64) NOT NULL DEFAULT 'default',
+ subject_template VARCHAR(998) NULL,
+ body_markdown LONGTEXT NOT NULL,
+ merge_fields_schema TEXT NULL,
+ created_by BIGINT UNSIGNED NULL,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL,
+ PRIMARY KEY (id),
+ KEY theme (theme),
+ KEY created_by (created_by)
+ ) $charset_collate;";
+ dbDelta($sql);
+
+ $sql = "CREATE TABLE {$prefix}newsletters (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ subject VARCHAR(998) NOT NULL,
+ from_email VARCHAR(320) NOT NULL,
+ from_name VARCHAR(255) NULL,
+ reply_to VARCHAR(320) NULL,
+ target_list_id BIGINT UNSIGNED NOT NULL,
+ template_id BIGINT UNSIGNED NULL,
+ theme VARCHAR(64) NULL,
+ body_markdown LONGTEXT NULL,
+ status VARCHAR(16) NOT NULL DEFAULT 'draft',
+ scheduled_at DATETIME NULL,
+ sent_at DATETIME NULL,
+ created_by BIGINT UNSIGNED NULL,
+ sent_by BIGINT UNSIGNED NULL,
+ summary_total INT UNSIGNED NOT NULL DEFAULT 0,
+ summary_sent INT UNSIGNED NOT NULL DEFAULT 0,
+ summary_opened INT UNSIGNED NOT NULL DEFAULT 0,
+ summary_clicked INT UNSIGNED NOT NULL DEFAULT 0,
+ summary_bounced INT UNSIGNED NOT NULL DEFAULT 0,
+ summary_complained INT UNSIGNED NOT NULL DEFAULT 0,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL,
+ PRIMARY KEY (id),
+ KEY status (status),
+ KEY scheduled_at (scheduled_at),
+ KEY status_sched (status, scheduled_at),
+ KEY created_by (created_by)
+ ) $charset_collate;";
+ dbDelta($sql);
+
+ $sql = "CREATE TABLE {$prefix}newsletter_deliveries (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ newsletter_id BIGINT UNSIGNED NOT NULL,
+ contact_id BIGINT UNSIGNED NOT NULL,
+ email_at_send VARCHAR(320) NOT NULL,
+ status VARCHAR(16) NOT NULL DEFAULT 'pending',
+ tracking_token VARCHAR(40) NOT NULL,
+ sent_at DATETIME NULL,
+ opened_at DATETIME NULL,
+ last_clicked_at DATETIME NULL,
+ clicks_count INT UNSIGNED NOT NULL DEFAULT 0,
+ bounce_reason TEXT NULL,
+ failure_reason TEXT NULL,
+ attempt_count SMALLINT UNSIGNED NOT NULL DEFAULT 0,
+ claimed_at DATETIME NULL,
+ is_test TINYINT(1) NOT NULL DEFAULT 0,
+ created_at DATETIME NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY tracking_token (tracking_token),
+ KEY nl_status (newsletter_id, status),
+ KEY contact_id (contact_id),
+ KEY status_claimed (status, claimed_at)
+ ) $charset_collate;";
+ dbDelta($sql);
+
+ // Add marketing_opt_out_at column to contacts if it doesn't exist.
+ $contacts_table = $prefix . 'contacts';
+ $col = $wpdb->get_var($wpdb->prepare(
+ "SHOW COLUMNS FROM `{$contacts_table}` LIKE %s",
+ 'marketing_opt_out_at'
+ ));
+ if (! $col) {
+ $wpdb->query("ALTER TABLE `{$contacts_table}` ADD COLUMN marketing_opt_out_at DATETIME NULL");
+ $wpdb->query("ALTER TABLE `{$contacts_table}` ADD INDEX marketing_opt_out_at (marketing_opt_out_at)");
+ }
+ }
+
/**
* Schedule cron events and register custom cron intervals.
*/
diff --git a/templates/newsletter_themes/branded.php b/templates/newsletter_themes/branded.php
new file mode 100644
index 0000000..be8ac54
--- /dev/null
+++ b/templates/newsletter_themes/branded.php
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+