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
92 changes: 92 additions & 0 deletions src/Escalated/Services/Email/MessageIdUtil.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;

namespace Escalated.Services.Email;

/// <summary>
/// Pure helpers for RFC 5322 Message-ID threading and signed Reply-To
/// addresses. Mirrors the NestJS reference
/// <c>escalated-nestjs/src/services/email/message-id.ts</c> and the
/// Spring <c>dev.escalated.services.email.MessageIdUtil</c>.
///
/// <para>Message-ID format:</para>
/// <list type="bullet">
/// <item><c>&lt;ticket-{ticketId}@{domain}&gt;</c> — initial ticket email</item>
/// <item><c>&lt;ticket-{ticketId}-reply-{replyId}@{domain}&gt;</c> — agent reply</item>
/// </list>
///
/// <para>Signed Reply-To format:
/// <c>reply+{ticketId}.{hmac8}@{domain}</c></para>
///
/// <para>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.</para>
/// </summary>
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);

/// <summary>
/// Build an RFC 5322 Message-ID. Pass <c>null</c> for <paramref name="replyId"/>
/// on the initial ticket email; the <c>-reply-{id}</c> tail is
/// appended only when <paramref name="replyId"/> is non-null.
/// </summary>
public static string BuildMessageId(long ticketId, long? replyId, string domain)
{
var body = replyId.HasValue
? $"ticket-{ticketId}-reply-{replyId.Value}"
: $"ticket-{ticketId}";
return $"<{body}@{domain}>";
}

/// <summary>
/// Extract the ticket id from a Message-ID we issued. Accepts the
/// header value with or without angle brackets. Returns <c>null</c>
/// when the input doesn't match our shape.
/// </summary>
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;
}

/// <summary>
/// Build a signed Reply-To address.
/// </summary>
public static string BuildReplyTo(long ticketId, string secret, string domain)
=> $"reply+{ticketId}.{Sign(ticketId, secret)}@{domain}";

/// <summary>
/// Verify a reply-to address (full <c>local@domain</c> or just the
/// local part). Returns the ticket id on match, <c>null</c> otherwise.
/// </summary>
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();
}
}
124 changes: 124 additions & 0 deletions tests/Escalated.Tests/Services/Email/MessageIdUtilTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using Escalated.Services.Email;
using Xunit;

namespace Escalated.Tests.Services.Email;

/// <summary>
/// Unit tests for <see cref="MessageIdUtil"/>. Mirrors the NestJS and
/// Spring reference test suites. Pure functions — no DI.
/// </summary>
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("<ticket-42@support.example.com>", id);
}

[Fact]
public void BuildMessageId_ReplyForm_AppendsReplyId()
{
var id = MessageIdUtil.BuildMessageId(42, 7, Domain);
Assert.Equal("<ticket-42-reply-7@support.example.com>", 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("<random@mail.com>")]
[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));
}
}
Loading