From 86bb1750d45e12bf60224523a104c6a357690337 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 04:28:02 -0400 Subject: [PATCH] feat(mail): wire Message_Id_Util into Email_Threading + add signed Reply-To MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors Email_Threading to delegate Message-ID generation to Message_Id_Util (added in #31) so the format matches the canonical NestJS reference and inbound Reply-To verification has something to check against. Format change: before: reply-{id}-ticket-{reference}@{domain} after: (anchor) (reply) Adds signed Reply-To to outbound mail headers when an inbound secret is configured. The HMAC prefix on the local part means inbound provider webhooks can route replies by ticket id without trusting the mail client's threading headers. New WP options (preferred over constants): escalated_email_domain — right-hand side of Message-IDs escalated_email_inbound_secret — HMAC key (empty → Reply-To skipped) Falls back to ESCALATED_EMAIL_DOMAIN / ESCALATED_EMAIL_INBOUND_SECRET PHP constants, then site_url() host, then 'localhost'. Updates existing test_generate_ticket_message_id / test_generate_reply_message_id assertions for the new format. Adds 4 new tests for the signed Reply-To path (blank / configured, via direct helper call and via add_threading_headers integration). --- includes/Mail/class-email-threading.php | 98 ++++++++++++++++++------- tests/Test_Email_Threading.php | 84 +++++++++++++++++++-- 2 files changed, 149 insertions(+), 33 deletions(-) diff --git a/includes/Mail/class-email-threading.php b/includes/Mail/class-email-threading.php index 49db39a..a79e7b3 100644 --- a/includes/Mail/class-email-threading.php +++ b/includes/Mail/class-email-threading.php @@ -3,6 +3,10 @@ /** * Email Threading - adds In-Reply-To, References, and Message-ID headers * to outbound WordPress emails for proper email threading support. + * + * Delegates to Message_Id_Util for header generation so the format + * matches the canonical NestJS reference and inbound Reply-To + * verification has something to check against. */ namespace Escalated\Mail; @@ -31,8 +35,6 @@ public function register(): void /** * Set the ticket context for the next outbound email. - * - * @param object $ticket The ticket object. */ public function set_ticket_context(object $ticket): void { @@ -42,9 +44,6 @@ public function set_ticket_context(object $ticket): void /** * Set the reply context for the next outbound email. - * - * @param object $reply The reply object. - * @param object $ticket The parent ticket object. */ public function set_reply_context(object $reply, object $ticket): void { @@ -68,23 +67,27 @@ public function add_threading_headers(array $args): array $reply = self::$current_reply; $domain = self::get_email_domain(); - // Generate Message-ID for this email. - $message_id = self::generate_message_id($ticket, $reply, $domain); - - // Build threading headers. $headers = is_array($args['headers']) ? $args['headers'] : []; if (is_string($args['headers'])) { $headers = explode("\n", $args['headers']); $headers = array_filter(array_map('trim', $headers)); } - $headers[] = sprintf('Message-ID: <%s>', $message_id); + $message_id = self::generate_message_id($ticket, $reply, $domain); + $headers[] = sprintf('Message-ID: %s', $message_id); - // For replies, reference the original ticket's message ID. if ($reply) { - $original_message_id = self::generate_ticket_message_id($ticket, $domain); - $headers[] = sprintf('In-Reply-To: <%s>', $original_message_id); - $headers[] = sprintf('References: <%s>', $original_message_id); + // Thread the reply off the ticket root. + $root = self::generate_ticket_message_id($ticket, $domain); + $headers[] = sprintf('In-Reply-To: %s', $root); + $headers[] = sprintf('References: %s', $root); + } + + // Signed Reply-To so the inbound webhook can verify ticket + // identity even when clients strip the Message-ID chain. + $reply_to = self::get_signed_reply_to($ticket, $domain); + if ($reply_to !== null) { + $headers[] = sprintf('Reply-To: %s', $reply_to); } $args['headers'] = $headers; @@ -97,42 +100,85 @@ public function add_threading_headers(array $args): array } /** - * Generate a Message-ID for a ticket. - * - * @param object $ticket The ticket object. - * @param string $domain The email domain. + * Generate a Message-ID for a ticket (the thread anchor). */ public static function generate_ticket_message_id(object $ticket, string $domain): string { - return sprintf('ticket-%s@%s', $ticket->reference, $domain); + return Message_Id_Util::build_message_id((int) $ticket->id, null, $domain); } /** - * Generate a Message-ID for a specific email. - * - * @param object $ticket The ticket object. - * @param object|null $reply The reply object, if any. - * @param string $domain The email domain. + * Generate a Message-ID for a specific email. Initial ticket + * notifications use the anchor form; replies use the reply form + * that includes the reply id. */ public static function generate_message_id(object $ticket, ?object $reply, string $domain): string { if ($reply) { - return sprintf('reply-%d-ticket-%s@%s', $reply->id, $ticket->reference, $domain); + return Message_Id_Util::build_message_id((int) $ticket->id, (int) $reply->id, $domain); } return self::generate_ticket_message_id($ticket, $domain); } /** - * Get the email domain for Message-ID generation. + * Return the signed Reply-To address for a ticket, or null when + * no inbound secret is configured. + * + * @return string|null Full `reply+{id}.{hmac8}@{domain}` address. + */ + public static function get_signed_reply_to(object $ticket, ?string $domain = null): ?string + { + $secret = self::get_inbound_secret(); + if ($secret === '') { + return null; + } + + return Message_Id_Util::build_reply_to( + (int) $ticket->id, + $secret, + $domain ?? self::get_email_domain() + ); + } + + /** + * Get the email domain for Message-ID generation. Resolution: + * 1. escalated_email_domain option (admin-configurable) + * 2. ESCALATED_EMAIL_DOMAIN PHP constant + * 3. WordPress site_url() host + * 4. 'localhost' */ public static function get_email_domain(): string { + $configured = get_option('escalated_email_domain', ''); + if (is_string($configured) && $configured !== '') { + return $configured; + } + if (defined('ESCALATED_EMAIL_DOMAIN') && ESCALATED_EMAIL_DOMAIN !== '') { + return (string) ESCALATED_EMAIL_DOMAIN; + } $site_url = wp_parse_url(site_url(), PHP_URL_HOST); return $site_url ?: 'localhost'; } + /** + * Return the HMAC secret used to sign Reply-To addresses. Empty + * string means "Reply-To signing disabled". + */ + public static function get_inbound_secret(): string + { + $configured = get_option('escalated_email_inbound_secret', ''); + if (is_string($configured) && $configured !== '') { + return $configured; + } + if (defined('ESCALATED_EMAIL_INBOUND_SECRET') && ESCALATED_EMAIL_INBOUND_SECRET !== '') { + return (string) ESCALATED_EMAIL_INBOUND_SECRET; + } + + return ''; + } + /** * Get the current ticket context (for testing). */ diff --git a/tests/Test_Email_Threading.php b/tests/Test_Email_Threading.php index fceff87..3957b1d 100644 --- a/tests/Test_Email_Threading.php +++ b/tests/Test_Email_Threading.php @@ -58,15 +58,19 @@ private function create_ticket(array $overrides = []): object // Message-ID Generation Tests // ========================================================================= + // Message-ID format matches the canonical Message_Id_Util shape: + // anchor: + // reply: + // Reference strings are no longer embedded — inbound routing uses + // the ticket id from the Message-ID or the signed Reply-To. + public function test_generate_ticket_message_id(): void { $ticket = $this->create_ticket(); $message_id = Email_Threading::generate_ticket_message_id($ticket, 'example.com'); - $this->assertStringContainsString('ticket-', $message_id); - $this->assertStringContainsString($ticket->reference, $message_id); - $this->assertStringContainsString('@example.com', $message_id); + $this->assertEquals("id}@example.com>", $message_id); } public function test_generate_reply_message_id(): void @@ -76,9 +80,7 @@ public function test_generate_reply_message_id(): void $message_id = Email_Threading::generate_message_id($ticket, $reply, 'example.com'); - $this->assertStringContainsString('reply-', $message_id); - $this->assertStringContainsString((string) $reply->id, $message_id); - $this->assertStringContainsString($ticket->reference, $message_id); + $this->assertEquals("id}-reply-{$reply->id}@example.com>", $message_id); } public function test_generate_message_id_without_reply(): void @@ -91,6 +93,74 @@ public function test_generate_message_id_without_reply(): void $this->assertStringNotContainsString('reply-', $message_id); } + // ========================================================================= + // Signed Reply-To Tests + // ========================================================================= + + public function test_signed_reply_to_returns_null_when_secret_blank(): void + { + delete_option('escalated_email_inbound_secret'); + $ticket = $this->create_ticket(); + + $this->assertNull(Email_Threading::get_signed_reply_to($ticket, 'example.com')); + } + + public function test_signed_reply_to_returns_signed_address_when_configured(): void + { + update_option('escalated_email_inbound_secret', 'test-secret-for-hmac'); + $ticket = $this->create_ticket(); + + $reply_to = Email_Threading::get_signed_reply_to($ticket, 'example.com'); + $this->assertNotNull($reply_to); + $this->assertMatchesRegularExpression( + "/^reply\\+{$ticket->id}\\.[a-f0-9]{8}@example\\.com$/", + $reply_to + ); + + delete_option('escalated_email_inbound_secret'); + } + + public function test_threading_headers_include_reply_to_when_secret_configured(): void + { + update_option('escalated_email_domain', 'support.test'); + update_option('escalated_email_inbound_secret', 'hmac-key'); + $threading = new Email_Threading; + $ticket = $this->create_ticket(); + + $threading->set_ticket_context($ticket); + $args = $threading->add_threading_headers([ + 'to' => 'user@example.com', + 'subject' => 'Test', + 'message' => 'Body', + 'headers' => [], + ]); + + $headers_str = implode("\n", $args['headers']); + $this->assertStringContainsString('Reply-To: reply+', $headers_str); + $this->assertStringContainsString('@support.test', $headers_str); + + delete_option('escalated_email_domain'); + delete_option('escalated_email_inbound_secret'); + } + + public function test_threading_headers_omit_reply_to_when_secret_blank(): void + { + delete_option('escalated_email_inbound_secret'); + $threading = new Email_Threading; + $ticket = $this->create_ticket(); + + $threading->set_ticket_context($ticket); + $args = $threading->add_threading_headers([ + 'to' => 'user@example.com', + 'subject' => 'Test', + 'message' => 'Body', + 'headers' => [], + ]); + + $headers_str = implode("\n", $args['headers']); + $this->assertStringNotContainsString('Reply-To:', $headers_str); + } + // ========================================================================= // Header Injection Tests // ========================================================================= @@ -113,7 +183,7 @@ public function test_add_threading_headers_for_ticket(): void $headers_str = implode("\n", $args['headers']); $this->assertStringContainsString('Message-ID:', $headers_str); - $this->assertStringContainsString($ticket->reference, $headers_str); + $this->assertStringContainsString("ticket-{$ticket->id}@", $headers_str); } public function test_add_threading_headers_for_reply(): void