From 9f4b1a3df0a1deef9b644bb5a0cbdeaae10b2f1e Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 04:55:03 -0400 Subject: [PATCH] feat(inbound): wire Message_Id_Util parse + verify into InboundEmailService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires Message_Id_Util (#31) + Email_Threading::get_inbound_secret (#32) into InboundEmailService::find_ticket_by_email so inbound mail routes to the correct ticket via canonical Message-ID parsing + signed Reply-To verification. Resolution order (first match wins): 1. In-Reply-To parsed via Message_Id_Util — cold-start path, no DB lookup required. 2. References parsed via Message_Id_Util, each id in order. 3. Signed Reply-To on message['toEmail'] (reply+{id}.{hmac8}@...) verified via Message_Id_Util. Survives clients that strip our threading headers; forged signatures are rejected. 4. Subject line reference tag (legacy). 5. inbound_emails.message_id lookup for any header id (weakest fallback). Extracts the header-id collection into a candidate_header_message_ids() helper so branches 1-2 and 5 share the same In-Reply-To + References parsing logic. 7 new WP integration tests cover every resolution strategy, the forged-signature rejection, the blank-secret skip path, and the no-match fallback. --- includes/Services/InboundEmailService.php | 96 +++++++++----- tests/Test_Inbound_Email_Service.php | 147 ++++++++++++++++++++++ 2 files changed, 210 insertions(+), 33 deletions(-) create mode 100644 tests/Test_Inbound_Email_Service.php diff --git a/includes/Services/InboundEmailService.php b/includes/Services/InboundEmailService.php index d66fe11..4efd0a4 100644 --- a/includes/Services/InboundEmailService.php +++ b/includes/Services/InboundEmailService.php @@ -150,30 +150,62 @@ public function process(array $message, string $adapter): ?object */ public function find_ticket_by_email(array $message): ?object { - global $wpdb; + $header_ids = $this->candidate_header_message_ids($message); + + // 1 + 2. Parse our own Message-IDs out of In-Reply-To / + // References via Message_Id_Util. Cold-start path — no DB + // lookup needed when the incoming thread references a + // Message-ID we issued. + foreach ($header_ids as $raw) { + $ticket_id = \Escalated\Mail\Message_Id_Util::parse_ticket_id_from_message_id($raw); + if ($ticket_id === null) { + continue; + } + $ticket = Ticket::find($ticket_id); + if ($ticket) { + return $ticket; + } + } - $subject = $message['subject'] ?? ''; + // 3. Signed Reply-To on the recipient address + // (reply+{id}.{hmac8}@...) verified with Message_Id_Util. + // Survives clients that strip our threading headers; forged + // signatures are rejected. + $secret = \Escalated\Mail\Email_Threading::get_inbound_secret(); + if ($secret !== '' && ! empty($message['toEmail'])) { + $verified = \Escalated\Mail\Message_Id_Util::verify_reply_to( + (string) $message['toEmail'], + $secret + ); + if ($verified !== null) { + $ticket = Ticket::find($verified); + if ($ticket) { + return $ticket; + } + } + } - // Check subject for ticket reference pattern like [ESC-00001]. + // 4. Subject line reference tag (legacy). + $subject = $message['subject'] ?? ''; if (preg_match('/\[([A-Z]+-\d{5,})\]/', $subject, $matches)) { - $reference = $matches[1]; - $ticket = Ticket::find_by_reference($reference); + $ticket = Ticket::find_by_reference($matches[1]); if ($ticket) { return $ticket; } } - // Check In-Reply-To header. + // 5. InboundEmail lookup (weakest fallback; relies on our own + // reply history). Ensures legacy deployments keep working + // during the Message-ID format transition. + global $wpdb; $inbound_table = Escalated::table('inbound_emails'); - - if (! empty($message['inReplyTo'])) { + foreach ($header_ids as $raw) { $related = $wpdb->get_row( $wpdb->prepare( "SELECT ticket_id FROM {$inbound_table} WHERE message_id = %s AND ticket_id IS NOT NULL LIMIT 1", - $message['inReplyTo'] + $raw ) ); - if ($related && ! empty($related->ticket_id)) { $ticket = Ticket::find((int) $related->ticket_id); if ($ticket) { @@ -182,35 +214,33 @@ public function find_ticket_by_email(array $message): ?object } } - // Check References header (may contain multiple message IDs). + return null; + } + + /** + * Return every candidate Message-ID from the inbound headers. + * + * @return array + */ + protected function candidate_header_message_ids(array $message): array + { + $ids = []; + if (! empty($message['inReplyTo'])) { + $ids[] = (string) $message['inReplyTo']; + } if (! empty($message['references'])) { - $references = is_array($message['references']) + $refs = is_array($message['references']) ? $message['references'] - : preg_split('/\s+/', $message['references']); - - foreach ($references as $ref_message_id) { - $ref_message_id = trim($ref_message_id); - if (empty($ref_message_id)) { - continue; - } - - $related = $wpdb->get_row( - $wpdb->prepare( - "SELECT ticket_id FROM {$inbound_table} WHERE message_id = %s AND ticket_id IS NOT NULL LIMIT 1", - $ref_message_id - ) - ); - - if ($related && ! empty($related->ticket_id)) { - $ticket = Ticket::find((int) $related->ticket_id); - if ($ticket) { - return $ticket; - } + : preg_split('/\s+/', (string) $message['references']); + foreach ((array) $refs as $ref) { + $ref = trim((string) $ref); + if ($ref !== '') { + $ids[] = $ref; } } } - return null; + return $ids; } /** diff --git a/tests/Test_Inbound_Email_Service.php b/tests/Test_Inbound_Email_Service.php new file mode 100644 index 0000000..45dd190 --- /dev/null +++ b/tests/Test_Inbound_Email_Service.php @@ -0,0 +1,147 @@ +service = new InboundEmailService; + $this->ticket_service = new TicketService; + $this->user_id = $this->factory->user->create(['role' => 'subscriber']); + } + + public function tear_down(): void + { + delete_option('escalated_email_inbound_secret'); + delete_option('escalated_email_domain'); + parent::tear_down(); + } + + private function make_ticket(array $overrides = []): object + { + $defaults = [ + 'subject' => 'Test', + 'description' => 'Body', + 'priority' => 'medium', + 'channel' => 'web', + ]; + + return $this->ticket_service->create($this->user_id, array_merge($defaults, $overrides)); + } + + private function make_message(array $overrides = []): array + { + return array_merge([ + 'fromEmail' => 'customer@example.com', + 'fromName' => 'Customer', + 'toEmail' => 'support@example.com', + 'subject' => 'hello', + 'bodyText' => 'body', + ], $overrides); + } + + public function test_finds_ticket_by_canonical_in_reply_to(): void + { + $ticket = $this->make_ticket(); + $message = $this->make_message([ + 'inReplyTo' => "id}@support.example.com>", + ]); + + $found = $this->service->find_ticket_by_email($message); + $this->assertNotNull($found); + $this->assertEquals($ticket->id, (int) $found->id); + } + + public function test_finds_ticket_by_canonical_references_header(): void + { + $ticket = $this->make_ticket(); + $message = $this->make_message([ + 'references' => " id}@support.example.com>", + ]); + + $found = $this->service->find_ticket_by_email($message); + $this->assertNotNull($found); + $this->assertEquals($ticket->id, (int) $found->id); + } + + public function test_finds_ticket_by_signed_reply_to(): void + { + update_option('escalated_email_domain', 'support.example.com'); + update_option('escalated_email_inbound_secret', 'test-secret'); + $ticket = $this->make_ticket(); + + $to = Message_Id_Util::build_reply_to( + (int) $ticket->id, + 'test-secret', + 'support.example.com' + ); + $message = $this->make_message(['toEmail' => $to]); + + $found = $this->service->find_ticket_by_email($message); + $this->assertNotNull($found); + $this->assertEquals($ticket->id, (int) $found->id); + } + + public function test_rejects_forged_reply_to_signature(): void + { + update_option('escalated_email_domain', 'support.example.com'); + update_option('escalated_email_inbound_secret', 'real-secret'); + $ticket = $this->make_ticket(); + + $forged = Message_Id_Util::build_reply_to( + (int) $ticket->id, + 'wrong-secret', + 'support.example.com' + ); + $message = $this->make_message(['toEmail' => $forged]); + + $found = $this->service->find_ticket_by_email($message); + $this->assertNull($found); + } + + public function test_ignores_signed_reply_to_when_secret_blank(): void + { + delete_option('escalated_email_inbound_secret'); + $ticket = $this->make_ticket(); + + $to = Message_Id_Util::build_reply_to( + (int) $ticket->id, + 'test-secret', + 'support.example.com' + ); + $message = $this->make_message(['toEmail' => $to]); + + $found = $this->service->find_ticket_by_email($message); + $this->assertNull($found); + } + + public function test_returns_null_when_nothing_matches(): void + { + $message = $this->make_message(['subject' => 'Completely unrelated']); + + $found = $this->service->find_ticket_by_email($message); + $this->assertNull($found); + } +}