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 @@ + + + + + + <?php echo esc_html($subject); ?> + + + +
+
+ + <?php echo esc_attr($brand['name']); ?> + +

+ +
+
+ +
+ + diff --git a/templates/newsletter_themes/default.php b/templates/newsletter_themes/default.php new file mode 100644 index 0000000..4a3572d --- /dev/null +++ b/templates/newsletter_themes/default.php @@ -0,0 +1,34 @@ + + + + + + <?php echo esc_html($subject); ?> + + + +
+
+ +
+ + From 17259b364b73caefd7136f5079a0595afb89cae6 Mon Sep 17 00:00:00 2001 From: Matt Gros Date: Sat, 23 May 2026 20:58:09 -0400 Subject: [PATCH 2/6] style(pint): apply project conventions to newsletter files --- PROMPT-CURSOR-SKILLS.md | 40 +++++++++++++++ includes/Models/Newsletter/Newsletter.php | 4 +- .../Models/Newsletter/NewsletterDelivery.php | 3 +- .../Newsletter/NewsletterRenderer.php | 49 +++++++++++++------ includes/class-activator.php | 6 +-- templates/newsletter_themes/branded.php | 10 ++-- templates/newsletter_themes/default.php | 6 +-- 7 files changed, 89 insertions(+), 29 deletions(-) create mode 100644 PROMPT-CURSOR-SKILLS.md diff --git a/PROMPT-CURSOR-SKILLS.md b/PROMPT-CURSOR-SKILLS.md new file mode 100644 index 0000000..3a37041 --- /dev/null +++ b/PROMPT-CURSOR-SKILLS.md @@ -0,0 +1,40 @@ +# Cursor task: skills-management parity for escalated-wordpress (greenfield) + +Self-contained brief. Read fully before doing anything. + +## Goal +Greenfield: implement the canonical Skills-management contract end-to-end on this WordPress plugin. + +**Tracking issue:** https://github.com/escalated-dev/escalated-wordpress/issues/55 +**Canonical contract:** https://github.com/escalated-dev/escalated-developer-context/blob/main/domain-model/skills-management.md +**ADR:** https://github.com/escalated-dev/escalated-developer-context/blob/main/decisions/2026-05-13-skills-routing-explicit-mapping.md +**Reference impl:** https://github.com/escalated-dev/escalated-laravel/pull/95 (closest PHP cousin) + https://github.com/escalated-dev/escalated-nestjs/pull/45 + +## Current state +No skills code today. WordPress plugin with REST controllers in `includes/Api/`. Activation hooks create custom tables. + +## Deliverables + +1. **Schema migration via the plugin's activation/upgrade flow** (look for an `Installer` / `Migrator` / `dbDelta` pattern): create `wp_escalated_skills`, `wp_escalated_agent_skills`, `wp_escalated_skill_routing_tags`, `wp_escalated_skill_routing_departments` (use the WordPress table prefix — these tables already use a prefixed `escalated_` namespace; mirror the existing convention). + +2. **REST controller** (`includes/Api/class-skill-controller.php`): 6 routes registered via `register_rest_route()` under `escalated/v1/admin/skills`. Use `permission_callback` to gate on admin role. + +3. **Data model layer**: WordPress plugins typically don't use ORMs — write small repository classes or use `$wpdb` directly, matching the conventions in the existing controllers. Wrap multi-table writes in `$wpdb->query('START TRANSACTION')` / `COMMIT` / `ROLLBACK`. + +4. **Routing service**: `class-skill-routing-service.php` implementing the explicit-mapping logic. + +5. **Inertia render**: the WordPress plugin serves the same shared Vue frontend. Confirm the admin route maps to `Escalated/Admin/Skills/Index` and `Form` page paths via whatever Inertia bridge the plugin uses. + +6. **Tests** (`tests/`): integration tests using WP_UnitTestCase (or whatever the repo uses). + +## Process + +1. `git checkout -b feat/admin-skills-management`. +2. Read the contract + look at how the existing controllers structure CRUD. +3. Implement and commit logically, reference #55. +4. Push, open PR titled `feat(skills): admin skills management parity (#55)`. + +## Constraints +- snake_case at the wire. +- The plugin's coding standard (WPCS) — run `composer phpcs` if defined. +- Stop after pushing. Don't include PROMPT-CURSOR-SKILLS.md in the PR. diff --git a/includes/Models/Newsletter/Newsletter.php b/includes/Models/Newsletter/Newsletter.php index 8bc4f72..ce47d2a 100644 --- a/includes/Models/Newsletter/Newsletter.php +++ b/includes/Models/Newsletter/Newsletter.php @@ -20,7 +20,8 @@ public static function table(): string public static function find(int $id): ?object { global $wpdb; - return $wpdb->get_row($wpdb->prepare("SELECT * FROM " . self::table() . " WHERE id = %d", $id)) ?: null; + + return $wpdb->get_row($wpdb->prepare('SELECT * FROM '.self::table().' WHERE id = %d', $id)) ?: null; } public static function create(array $attrs): int @@ -31,6 +32,7 @@ public static function create(array $attrs): int self::table(), array_merge(['status' => 'draft', 'created_at' => $now, 'updated_at' => $now], $attrs) ); + return (int) $wpdb->insert_id; } diff --git a/includes/Models/Newsletter/NewsletterDelivery.php b/includes/Models/Newsletter/NewsletterDelivery.php index 05058a0..9155112 100644 --- a/includes/Models/Newsletter/NewsletterDelivery.php +++ b/includes/Models/Newsletter/NewsletterDelivery.php @@ -16,8 +16,9 @@ public static function table(): string public static function find_by_token(string $token): ?object { global $wpdb; + return $wpdb->get_row($wpdb->prepare( - "SELECT * FROM " . self::table() . " WHERE tracking_token = %s", + 'SELECT * FROM '.self::table().' WHERE tracking_token = %s', $token )) ?: null; } diff --git a/includes/Services/Newsletter/NewsletterRenderer.php b/includes/Services/Newsletter/NewsletterRenderer.php index dc5a837..aa4b122 100644 --- a/includes/Services/Newsletter/NewsletterRenderer.php +++ b/includes/Services/Newsletter/NewsletterRenderer.php @@ -38,12 +38,12 @@ public function render(object $delivery, object $newsletter, object $contact, ?o public function unsubscribe_url(object $delivery): string { - return untrailingslashit(home_url()) . '/escalated/n/u/' . $delivery->tracking_token; + 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; + return untrailingslashit(home_url()).'/escalated/n/v/'.$delivery->tracking_token; } private function brand(): array @@ -63,7 +63,8 @@ private function markdown_to_html(string $md): string return $rendered; } $escaped = esc_html($md); - return '

' . implode('

', preg_split('/\n{2,}/', $escaped)) . '

'; + + return '

'.implode('

', preg_split('/\n{2,}/', $escaped)).'

'; } private function resolve_merge_fields(string $html, object $contact, object $delivery): string @@ -86,8 +87,10 @@ private function resolve_path(string $path, object $contact, object $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 ''; } @@ -95,15 +98,16 @@ private function render_theme(string $slug, array $ctx): string { $themes_dir = apply_filters( 'escalated_newsletter_themes_dir', - ESCALATED_PLUGIN_DIR . 'templates/newsletter_themes' + ESCALATED_PLUGIN_DIR.'templates/newsletter_themes' ); - $path = $themes_dir . '/' . $slug . '.php'; + $path = $themes_dir.'/'.$slug.'.php'; if (! file_exists($path)) { - $path = $themes_dir . '/default.php'; + $path = $themes_dir.'/default.php'; } extract($ctx, EXTR_OVERWRITE); ob_start(); include $path; + return (string) ob_get_clean(); } @@ -111,26 +115,39 @@ 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]; + $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]; + 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; + $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 = ''; + $url = untrailingslashit(home_url()).'/escalated/n/o/'.$delivery->tracking_token.'.gif'; + $pixel = ''; if (strpos($html, '') !== false) { - return str_replace('', $pixel . '', $html); + return str_replace('', $pixel.'', $html); } - return $html . $pixel; + + return $html.$pixel; } } diff --git a/includes/class-activator.php b/includes/class-activator.php index 61c4459..ff5f749 100644 --- a/includes/class-activator.php +++ b/includes/class-activator.php @@ -874,8 +874,8 @@ private static function insert_default_settings(): void public static function create_newsletter_tables(): void { global $wpdb; - require_once ABSPATH . 'wp-admin/includes/upgrade.php'; - $prefix = $wpdb->prefix . 'escalated_'; + require_once ABSPATH.'wp-admin/includes/upgrade.php'; + $prefix = $wpdb->prefix.'escalated_'; $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$prefix}newsletter_lists ( @@ -978,7 +978,7 @@ public static function create_newsletter_tables(): void dbDelta($sql); // Add marketing_opt_out_at column to contacts if it doesn't exist. - $contacts_table = $prefix . 'contacts'; + $contacts_table = $prefix.'contacts'; $col = $wpdb->get_var($wpdb->prepare( "SHOW COLUMNS FROM `{$contacts_table}` LIKE %s", 'marketing_opt_out_at' diff --git a/templates/newsletter_themes/branded.php b/templates/newsletter_themes/branded.php index be8ac54..74f4f27 100644 --- a/templates/newsletter_themes/branded.php +++ b/templates/newsletter_themes/branded.php @@ -20,11 +20,11 @@
- + <?php echo esc_attr($brand['name']); ?> - +

- +
diff --git a/templates/newsletter_themes/default.php b/templates/newsletter_themes/default.php index 4a3572d..4bc4749 100644 --- a/templates/newsletter_themes/default.php +++ b/templates/newsletter_themes/default.php @@ -18,16 +18,16 @@
-
+
From cc0edd630a21252d439409bebaa70902ac1a0e1d Mon Sep 17 00:00:00 2001 From: Matt Gros Date: Sat, 23 May 2026 21:04:04 -0400 Subject: [PATCH 3/6] style(pint): fix remaining model files --- includes/Models/Newsletter/NewsletterList.php | 5 ++++- includes/Models/Newsletter/NewsletterTemplate.php | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/includes/Models/Newsletter/NewsletterList.php b/includes/Models/Newsletter/NewsletterList.php index 9166a40..c339c2f 100644 --- a/includes/Models/Newsletter/NewsletterList.php +++ b/includes/Models/Newsletter/NewsletterList.php @@ -7,6 +7,7 @@ class NewsletterList { public const KIND_STATIC = 'static'; + public const KIND_DYNAMIC = 'dynamic'; public static function table(): string @@ -17,7 +18,8 @@ public static function table(): string public static function find(int $id): ?object { global $wpdb; - return $wpdb->get_row($wpdb->prepare("SELECT * FROM " . self::table() . " WHERE id = %d", $id)) ?: null; + + return $wpdb->get_row($wpdb->prepare('SELECT * FROM '.self::table().' WHERE id = %d', $id)) ?: null; } public static function create(array $attrs): int @@ -28,6 +30,7 @@ public static function create(array $attrs): int self::table(), array_merge(['created_at' => $now, 'updated_at' => $now], $attrs) ); + return (int) $wpdb->insert_id; } } diff --git a/includes/Models/Newsletter/NewsletterTemplate.php b/includes/Models/Newsletter/NewsletterTemplate.php index 827bdb3..c6d650c 100644 --- a/includes/Models/Newsletter/NewsletterTemplate.php +++ b/includes/Models/Newsletter/NewsletterTemplate.php @@ -14,6 +14,7 @@ public static function table(): string public static function find(int $id): ?object { global $wpdb; - return $wpdb->get_row($wpdb->prepare("SELECT * FROM " . self::table() . " WHERE id = %d", $id)) ?: null; + + return $wpdb->get_row($wpdb->prepare('SELECT * FROM '.self::table().' WHERE id = %d', $id)) ?: null; } } From 54fc78c65f65e738ce05db575b8da1744fd98934 Mon Sep 17 00:00:00 2001 From: Matt Gros Date: Sat, 23 May 2026 21:10:03 -0400 Subject: [PATCH 4/6] ci: retry WordPress test suite download From a29e99057572b9f5c8faf7cb53b3fed6ae5a609b Mon Sep 17 00:00:00 2001 From: Matt Gros Date: Sat, 23 May 2026 21:12:00 -0400 Subject: [PATCH 5/6] ci(wp): add retry+verify to WordPress test suite download --- .github/workflows/run-tests.yml | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 5f1b9aa..3df9fda 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -57,18 +57,41 @@ jobs: - name: Install WordPress test suite run: | + set -o pipefail WP_TESTS_DIR=/tmp/wordpress-tests-lib WP_CORE_DIR=/tmp/wordpress + # Download with retries: WordPress.org occasionally rate-limits or returns + # HTML instead of a tarball when under load, which produces + # "gzip: stdin: not in gzip format" — retrying clears it. + download_with_retry() { + local url="$1" + local dest="$2" + shift 2 + for attempt in 1 2 3 4 5; do + if curl -sSL --retry 3 --retry-delay 5 --fail "$url" -o /tmp/wp-download.tar.gz; then + if tar tzf /tmp/wp-download.tar.gz > /dev/null 2>&1; then + tar xz --strip-components=1 -C "$dest" "$@" -f /tmp/wp-download.tar.gz + rm -f /tmp/wp-download.tar.gz + return 0 + fi + fi + echo "Attempt $attempt failed for $url, sleeping before retry..." + sleep $((attempt * 5)) + done + echo "Failed to download $url after 5 attempts" >&2 + return 1 + } + # Download WordPress mkdir -p "$WP_CORE_DIR" - curl -sL https://wordpress.org/latest.tar.gz | tar xz --strip-components=1 -C "$WP_CORE_DIR" + download_with_retry https://wordpress.org/latest.tar.gz "$WP_CORE_DIR" # Download WordPress test suite mkdir -p "$WP_TESTS_DIR" WP_VERSION=$(php -r "require '$WP_CORE_DIR/wp-includes/version.php'; echo \$wp_version;") WP_BRANCH="${WP_VERSION%.*}" - curl -sL "https://github.com/WordPress/wordpress-develop/archive/refs/heads/${WP_BRANCH}.tar.gz" | tar xz --strip-components=1 -C "$WP_TESTS_DIR" "wordpress-develop-${WP_BRANCH}/tests/phpunit/includes" "wordpress-develop-${WP_BRANCH}/tests/phpunit/data" + download_with_retry "https://github.com/WordPress/wordpress-develop/archive/refs/heads/${WP_BRANCH}.tar.gz" "$WP_TESTS_DIR" "wordpress-develop-${WP_BRANCH}/tests/phpunit/includes" "wordpress-develop-${WP_BRANCH}/tests/phpunit/data" # Move extracted contents to expected layout mv "$WP_TESTS_DIR/tests/phpunit/includes" "$WP_TESTS_DIR/includes" mv "$WP_TESTS_DIR/tests/phpunit/data" "$WP_TESTS_DIR/data" From 2c2492535c3b34579b7e3ca92e1740c54d93f86c Mon Sep 17 00:00:00 2001 From: Matt Gros Date: Sat, 23 May 2026 21:15:38 -0400 Subject: [PATCH 6/6] ci(wp): compute MAJOR.MINOR branch (not just MAJOR) for wordpress-develop archive URL --- .github/workflows/run-tests.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 3df9fda..cdcae21 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -90,7 +90,10 @@ jobs: # Download WordPress test suite mkdir -p "$WP_TESTS_DIR" WP_VERSION=$(php -r "require '$WP_CORE_DIR/wp-includes/version.php'; echo \$wp_version;") - WP_BRANCH="${WP_VERSION%.*}" + # Compute MAJOR.MINOR branch name (e.g. "6.7" from "6.7.1" or "6.7"; "7.0" from "7.0"). + # The legacy `${WP_VERSION%.*}` form strips a `7.0` to just `7`, which is not a real + # wordpress-develop branch — they're always named MAJOR.MINOR. + WP_BRANCH=$(echo "$WP_VERSION" | awk -F. '{ if ($2 != "") print $1"."$2; else print $1 }') download_with_retry "https://github.com/WordPress/wordpress-develop/archive/refs/heads/${WP_BRANCH}.tar.gz" "$WP_TESTS_DIR" "wordpress-develop-${WP_BRANCH}/tests/phpunit/includes" "wordpress-develop-${WP_BRANCH}/tests/phpunit/data" # Move extracted contents to expected layout mv "$WP_TESTS_DIR/tests/phpunit/includes" "$WP_TESTS_DIR/includes"