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
98 changes: 72 additions & 26 deletions includes/Mail/class-email-threading.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
{
Expand All @@ -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
{
Expand All @@ -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;
Expand All @@ -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).
*/
Expand Down
84 changes: 77 additions & 7 deletions tests/Test_Email_Threading.php
Original file line number Diff line number Diff line change
Expand Up @@ -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: <ticket-{id}@{domain}>
// reply: <ticket-{id}-reply-{replyId}@{domain}>
// 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("<ticket-{$ticket->id}@example.com>", $message_id);
}

public function test_generate_reply_message_id(): void
Expand All @@ -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("<ticket-{$ticket->id}-reply-{$reply->id}@example.com>", $message_id);
}

public function test_generate_message_id_without_reply(): void
Expand All @@ -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
// =========================================================================
Expand All @@ -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
Expand Down