diff --git a/includes/Mail/class-message-id-util.php b/includes/Mail/class-message-id-util.php new file mode 100644 index 0000000..2e6db3d --- /dev/null +++ b/includes/Mail/class-message-id-util.php @@ -0,0 +1,94 @@ + initial ticket email + * 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); + } +} diff --git a/tests/Test_Message_Id_Util.php b/tests/Test_Message_Id_Util.php new file mode 100644 index 0000000..73d5c90 --- /dev/null +++ b/tests/Test_Message_Id_Util.php @@ -0,0 +1,120 @@ +assertEquals('', $id); + } + + public function test_build_message_id_reply_form(): void + { + $id = Message_Id_Util::build_message_id(42, 7, self::DOMAIN); + $this->assertEquals('', $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('')); + $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)); + } +}