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
94 changes: 94 additions & 0 deletions includes/Mail/class-message-id-util.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

namespace Escalated\Mail;

/**
* Pure helpers for RFC 5322 Message-ID threading and signed Reply-To
* addresses. Mirrors the NestJS reference
* `escalated-nestjs/src/services/email/message-id.ts` and the Spring
* `dev.escalated.services.email.MessageIdUtil`.
*
* Coexists with the existing `Email_Threading` class during the migration
* window. New outbound paths should prefer this util so inbound Reply-To
* verification has something to check against.
*
* ## Message-ID format
* <ticket-{ticketId}@{domain}> initial ticket email
* <ticket-{ticketId}-reply-{replyId}@{domain}> agent reply
*
* ## Signed Reply-To format
* reply+{ticketId}.{hmac8}@{domain}
*
* The signed Reply-To carries ticket identity even when clients strip our
* Message-ID / In-Reply-To headers — the inbound provider webhook can
* verify the HMAC prefix before routing a reply to its ticket.
*/
class Message_Id_Util
{
/**
* Build an RFC 5322 Message-ID. Pass `null` for `$reply_id` on the
* initial ticket email; the `-reply-{id}` tail is appended only when
* `$reply_id` is non-null.
*/
public static function build_message_id(int $ticket_id, ?int $reply_id, string $domain): string
{
$body = $reply_id !== null
? sprintf('ticket-%d-reply-%d', $ticket_id, $reply_id)
: sprintf('ticket-%d', $ticket_id);

return sprintf('<%s@%s>', $body, $domain);
}

/**
* Extract the ticket id from a Message-ID we issued. Accepts the
* header value with or without angle brackets. Returns `null` when
* the input doesn't match our shape.
*/
public static function parse_ticket_id_from_message_id(?string $raw): ?int
{
if ($raw === null || $raw === '') {
return null;
}
if (preg_match('/ticket-(\d+)(?:-reply-\d+)?@/i', $raw, $m)) {
return (int) $m[1];
}

return null;
}

/**
* Build a signed Reply-To address.
*/
public static function build_reply_to(int $ticket_id, string $secret, string $domain): string
{
return sprintf('reply+%d.%s@%s', $ticket_id, self::sign($ticket_id, $secret), $domain);
}

/**
* Verify a reply-to address (full `local@domain` or just the local
* part). Returns the ticket id on success, `null` otherwise.
*/
public static function verify_reply_to(?string $address, string $secret): ?int
{
if ($address === null || $address === '') {
return null;
}
$at = strpos($address, '@');
$local = $at !== false ? substr($address, 0, $at) : $address;
if (! preg_match('/^reply\+(\d+)\.([a-f0-9]{8})$/i', $local, $m)) {
return null;
}
$ticket_id = (int) $m[1];
$expected = self::sign($ticket_id, $secret);

return hash_equals(strtolower($expected), strtolower($m[2])) ? $ticket_id : null;
}

/**
* 8-character HMAC-SHA256 prefix over the ticket id.
*/
private static function sign(int $ticket_id, string $secret): string
{
return substr(hash_hmac('sha256', (string) $ticket_id, $secret), 0, 8);
}
}
120 changes: 120 additions & 0 deletions tests/Test_Message_Id_Util.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php

/**
* Tests for Message_Id_Util — pure helpers for RFC 5322 Message-ID
* threading + signed Reply-To.
*
* Mirrors the NestJS and Spring reference test suites. Pure functions,
* no WP test harness required.
*/

use Escalated\Mail\Message_Id_Util;
use PHPUnit\Framework\TestCase;

class Test_Message_Id_Util extends TestCase
{
private const DOMAIN = 'support.example.com';

private const SECRET = 'test-secret-long-enough-for-hmac';

public function test_build_message_id_initial_ticket(): void
{
$id = Message_Id_Util::build_message_id(42, null, self::DOMAIN);
$this->assertEquals('<ticket-42@support.example.com>', $id);
}

public function test_build_message_id_reply_form(): void
{
$id = Message_Id_Util::build_message_id(42, 7, self::DOMAIN);
$this->assertEquals('<ticket-42-reply-7@support.example.com>', $id);
}

public function test_parse_ticket_id_from_built_message_id(): void
{
$initial = Message_Id_Util::build_message_id(42, null, self::DOMAIN);
$reply = Message_Id_Util::build_message_id(42, 7, self::DOMAIN);

$this->assertEquals(42, Message_Id_Util::parse_ticket_id_from_message_id($initial));
$this->assertEquals(42, Message_Id_Util::parse_ticket_id_from_message_id($reply));
}

public function test_parse_ticket_id_accepts_value_without_brackets(): void
{
$this->assertEquals(99, Message_Id_Util::parse_ticket_id_from_message_id('ticket-99@example.com'));
}

public function test_parse_ticket_id_returns_null_for_unrelated_input(): void
{
$this->assertNull(Message_Id_Util::parse_ticket_id_from_message_id(null));
$this->assertNull(Message_Id_Util::parse_ticket_id_from_message_id(''));
$this->assertNull(Message_Id_Util::parse_ticket_id_from_message_id('<random@mail.com>'));
$this->assertNull(Message_Id_Util::parse_ticket_id_from_message_id('ticket-abc@example.com'));
}

public function test_build_reply_to_is_stable(): void
{
$first = Message_Id_Util::build_reply_to(42, self::SECRET, self::DOMAIN);
$again = Message_Id_Util::build_reply_to(42, self::SECRET, self::DOMAIN);

$this->assertEquals($first, $again);
$this->assertMatchesRegularExpression(
'/^reply\+42\.[a-f0-9]{8}@support\.example\.com$/',
$first
);
}

public function test_build_reply_to_different_tickets_produce_different_signatures(): void
{
$a = Message_Id_Util::build_reply_to(42, self::SECRET, self::DOMAIN);
$b = Message_Id_Util::build_reply_to(43, self::SECRET, self::DOMAIN);
$a_local = substr($a, 0, strpos($a, '@'));
$b_local = substr($b, 0, strpos($b, '@'));
$this->assertNotEquals($a_local, $b_local);
}

public function test_verify_reply_to_round_trips(): void
{
$address = Message_Id_Util::build_reply_to(42, self::SECRET, self::DOMAIN);
$this->assertEquals(42, Message_Id_Util::verify_reply_to($address, self::SECRET));
}

public function test_verify_reply_to_accepts_local_part_only(): void
{
$address = Message_Id_Util::build_reply_to(42, self::SECRET, self::DOMAIN);
$local = substr($address, 0, strpos($address, '@'));
$this->assertEquals(42, Message_Id_Util::verify_reply_to($local, self::SECRET));
}

public function test_verify_reply_to_rejects_tampered_signature(): void
{
$address = Message_Id_Util::build_reply_to(42, self::SECRET, self::DOMAIN);
$at = strpos($address, '@');
$local = substr($address, 0, $at);
$last = $local[strlen($local) - 1];
$tampered = substr($local, 0, -1).($last === '0' ? '1' : '0').substr($address, $at);

$this->assertNull(Message_Id_Util::verify_reply_to($tampered, self::SECRET));
}

public function test_verify_reply_to_rejects_wrong_secret(): void
{
$address = Message_Id_Util::build_reply_to(42, self::SECRET, self::DOMAIN);
$this->assertNull(Message_Id_Util::verify_reply_to($address, 'different-secret'));
}

public function test_verify_reply_to_rejects_malformed_input(): void
{
$this->assertNull(Message_Id_Util::verify_reply_to(null, self::SECRET));
$this->assertNull(Message_Id_Util::verify_reply_to('', self::SECRET));
$this->assertNull(Message_Id_Util::verify_reply_to('alice@example.com', self::SECRET));
$this->assertNull(Message_Id_Util::verify_reply_to('reply@example.com', self::SECRET));
$this->assertNull(Message_Id_Util::verify_reply_to('reply+abc.deadbeef@example.com', self::SECRET));
}

public function test_verify_reply_to_is_case_insensitive_on_hex(): void
{
$address = Message_Id_Util::build_reply_to(42, self::SECRET, self::DOMAIN);
$upper = strtoupper($address);
$this->assertEquals(42, Message_Id_Util::verify_reply_to($upper, self::SECRET));
}
}
Loading