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); + } +}