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)); + } +}