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