Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 63 additions & 33 deletions includes/Services/InboundEmailService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<string>
*/
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;
}

/**
Expand Down
147 changes: 147 additions & 0 deletions tests/Test_Inbound_Email_Service.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?php

/**
* Tests for InboundEmailService::find_ticket_by_email resolution order.
*
* Verifies that the Message_Id_Util wire-up resolves canonical
* Message-IDs out of In-Reply-To / References and verifies signed
* Reply-To addresses on the recipient.
*/

use Escalated\Mail\Message_Id_Util;
use Escalated\Models\Ticket;
use Escalated\Services\InboundEmailService;
use Escalated\Services\TicketService;

class Test_Inbound_Email_Service extends WP_UnitTestCase
{
private InboundEmailService $service;

private TicketService $ticket_service;

private int $user_id;

public function set_up(): void
{
parent::set_up();

\Escalated\Activator::activate();

$this->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' => "<ticket-{$ticket->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' => "<unrelated@mail.com> <ticket-{$ticket->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);
}
}