From 4116e03c22f8454f2beb7c572ad95732daa273fc Mon Sep 17 00:00:00 2001
From: Matt Gros <3311227+mpge@users.noreply.github.com>
Date: Fri, 24 Apr 2026 03:55:20 -0400
Subject: [PATCH] feat(email): add MessageIdUtil for RFC 5322 + signed Reply-To
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ports the NestJS email/message-id.ts helpers to .NET. Mirrors the
Spring dev.escalated.services.email.MessageIdUtil and the WordPress
Message_Id_Util.
API:
BuildMessageId(ticketId, replyId?, domain)
ParseTicketIdFromMessageId(raw)
BuildReplyTo(ticketId, secret, domain)
VerifyReplyTo(address, secret)
Uses HMACSHA256 + CryptographicOperations.FixedTimeEquals for
timing-safe verification.
Pure static helpers, no DI — 16 xUnit tests ([Theory] for the
malformed-input cases) covering round-trip, tamper rejection,
case-insensitive hex, malformed input, local-part-only acceptance.
Follow-up PR will wire the util into EmailService so outbound
notifications carry the RFC-compliant Message-ID + signed Reply-To,
and add an EscalatedOptions.Email config block for domain + inbound
secret.
---
src/Escalated/Services/Email/MessageIdUtil.cs | 92 +++++++++++++
.../Services/Email/MessageIdUtilTests.cs | 124 ++++++++++++++++++
2 files changed, 216 insertions(+)
create mode 100644 src/Escalated/Services/Email/MessageIdUtil.cs
create mode 100644 tests/Escalated.Tests/Services/Email/MessageIdUtilTests.cs
diff --git a/src/Escalated/Services/Email/MessageIdUtil.cs b/src/Escalated/Services/Email/MessageIdUtil.cs
new file mode 100644
index 0000000..48d9901
--- /dev/null
+++ b/src/Escalated/Services/Email/MessageIdUtil.cs
@@ -0,0 +1,92 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace Escalated.Services.Email;
+
+///
+/// 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.
+///
+/// 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 verifies the 8-char HMAC-SHA256 prefix before routing.
+///
+public static class MessageIdUtil
+{
+ private static readonly Regex TicketIdPattern =
+ new(@"ticket-(\d+)(?:-reply-\d+)?@", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ private static readonly Regex ReplyLocalPattern =
+ new(@"^reply\+(\d+)\.([a-f0-9]{8})$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ ///
+ /// Build an RFC 5322 Message-ID. Pass null for
+ /// on the initial ticket email; the -reply-{id} tail is
+ /// appended only when is non-null.
+ ///
+ public static string BuildMessageId(long ticketId, long? replyId, string domain)
+ {
+ var body = replyId.HasValue
+ ? $"ticket-{ticketId}-reply-{replyId.Value}"
+ : $"ticket-{ticketId}";
+ return $"<{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 long? ParseTicketIdFromMessageId(string? raw)
+ {
+ if (string.IsNullOrEmpty(raw)) return null;
+ var match = TicketIdPattern.Match(raw);
+ if (!match.Success) return null;
+ return long.TryParse(match.Groups[1].Value, out var id) ? id : null;
+ }
+
+ ///
+ /// Build a signed Reply-To address.
+ ///
+ public static string BuildReplyTo(long ticketId, string secret, string domain)
+ => $"reply+{ticketId}.{Sign(ticketId, secret)}@{domain}";
+
+ ///
+ /// Verify a reply-to address (full local@domain or just the
+ /// local part). Returns the ticket id on match, null otherwise.
+ ///
+ public static long? VerifyReplyTo(string? address, string secret)
+ {
+ if (string.IsNullOrEmpty(address)) return null;
+ var at = address.IndexOf('@');
+ var local = at > 0 ? address[..at] : address;
+ var match = ReplyLocalPattern.Match(local);
+ if (!match.Success) return null;
+ if (!long.TryParse(match.Groups[1].Value, out var ticketId)) return null;
+ var expected = Sign(ticketId, secret);
+ return CryptographicOperations.FixedTimeEquals(
+ Encoding.ASCII.GetBytes(expected.ToLowerInvariant()),
+ Encoding.ASCII.GetBytes(match.Groups[2].Value.ToLowerInvariant()))
+ ? ticketId
+ : null;
+ }
+
+ private static string Sign(long ticketId, string secret)
+ {
+ using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
+ var digest = hmac.ComputeHash(Encoding.UTF8.GetBytes(ticketId.ToString()));
+ return Convert.ToHexString(digest, 0, 4).ToLowerInvariant();
+ }
+}
diff --git a/tests/Escalated.Tests/Services/Email/MessageIdUtilTests.cs b/tests/Escalated.Tests/Services/Email/MessageIdUtilTests.cs
new file mode 100644
index 0000000..e9a0a1d
--- /dev/null
+++ b/tests/Escalated.Tests/Services/Email/MessageIdUtilTests.cs
@@ -0,0 +1,124 @@
+using Escalated.Services.Email;
+using Xunit;
+
+namespace Escalated.Tests.Services.Email;
+
+///
+/// Unit tests for . Mirrors the NestJS and
+/// Spring reference test suites. Pure functions — no DI.
+///
+public class MessageIdUtilTests
+{
+ private const string Domain = "support.example.com";
+ private const string Secret = "test-secret-long-enough-for-hmac";
+
+ [Fact]
+ public void BuildMessageId_InitialTicket_UsesTicketForm()
+ {
+ var id = MessageIdUtil.BuildMessageId(42, null, Domain);
+ Assert.Equal("", id);
+ }
+
+ [Fact]
+ public void BuildMessageId_ReplyForm_AppendsReplyId()
+ {
+ var id = MessageIdUtil.BuildMessageId(42, 7, Domain);
+ Assert.Equal("", id);
+ }
+
+ [Fact]
+ public void ParseTicketIdFromMessageId_RoundTripsBuiltId()
+ {
+ var initial = MessageIdUtil.BuildMessageId(42, null, Domain);
+ var reply = MessageIdUtil.BuildMessageId(42, 7, Domain);
+
+ Assert.Equal(42L, MessageIdUtil.ParseTicketIdFromMessageId(initial));
+ Assert.Equal(42L, MessageIdUtil.ParseTicketIdFromMessageId(reply));
+ }
+
+ [Fact]
+ public void ParseTicketIdFromMessageId_AcceptsValueWithoutBrackets()
+ {
+ Assert.Equal(99L, MessageIdUtil.ParseTicketIdFromMessageId("ticket-99@example.com"));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("")]
+ [InlineData("ticket-abc@example.com")]
+ public void ParseTicketIdFromMessageId_ReturnsNullForUnrelatedInput(string? input)
+ {
+ Assert.Null(MessageIdUtil.ParseTicketIdFromMessageId(input));
+ }
+
+ [Fact]
+ public void BuildReplyTo_IsStableForSameInputs()
+ {
+ var first = MessageIdUtil.BuildReplyTo(42, Secret, Domain);
+ var again = MessageIdUtil.BuildReplyTo(42, Secret, Domain);
+
+ Assert.Equal(first, again);
+ Assert.Matches(@"^reply\+42\.[a-f0-9]{8}@support\.example\.com$", first);
+ }
+
+ [Fact]
+ public void BuildReplyTo_DifferentTicketsProduceDifferentSignatures()
+ {
+ var a = MessageIdUtil.BuildReplyTo(42, Secret, Domain);
+ var b = MessageIdUtil.BuildReplyTo(43, Secret, Domain);
+ Assert.NotEqual(a[..a.IndexOf('@')], b[..b.IndexOf('@')]);
+ }
+
+ [Fact]
+ public void VerifyReplyTo_RoundTripsBuiltAddress()
+ {
+ var address = MessageIdUtil.BuildReplyTo(42, Secret, Domain);
+ Assert.Equal(42L, MessageIdUtil.VerifyReplyTo(address, Secret));
+ }
+
+ [Fact]
+ public void VerifyReplyTo_AcceptsLocalPartOnly()
+ {
+ var address = MessageIdUtil.BuildReplyTo(42, Secret, Domain);
+ var local = address[..address.IndexOf('@')];
+ Assert.Equal(42L, MessageIdUtil.VerifyReplyTo(local, Secret));
+ }
+
+ [Fact]
+ public void VerifyReplyTo_RejectsTamperedSignature()
+ {
+ var address = MessageIdUtil.BuildReplyTo(42, Secret, Domain);
+ var at = address.IndexOf('@');
+ var local = address[..at];
+ var last = local[^1];
+ var tampered = local[..^1] + (last == '0' ? '1' : '0') + address[at..];
+
+ Assert.Null(MessageIdUtil.VerifyReplyTo(tampered, Secret));
+ }
+
+ [Fact]
+ public void VerifyReplyTo_RejectsWrongSecret()
+ {
+ var address = MessageIdUtil.BuildReplyTo(42, Secret, Domain);
+ Assert.Null(MessageIdUtil.VerifyReplyTo(address, "different-secret"));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("alice@example.com")]
+ [InlineData("reply@example.com")]
+ [InlineData("reply+abc.deadbeef@example.com")]
+ public void VerifyReplyTo_RejectsMalformedInput(string? input)
+ {
+ Assert.Null(MessageIdUtil.VerifyReplyTo(input, Secret));
+ }
+
+ [Fact]
+ public void VerifyReplyTo_IsCaseInsensitiveOnHex()
+ {
+ var address = MessageIdUtil.BuildReplyTo(42, Secret, Domain);
+ Assert.Equal(42L, MessageIdUtil.VerifyReplyTo(address.ToUpperInvariant(), Secret));
+ }
+}