Skip to content

feat(inbound): scaffold InboundMessage + IInboundEmailParser + InboundEmailRouter#23

Open
mpge wants to merge 1 commit intofeat/email-message-idfrom
feat/inbound-email-webhook
Open

feat(inbound): scaffold InboundMessage + IInboundEmailParser + InboundEmailRouter#23
mpge wants to merge 1 commit intofeat/email-message-idfrom
feat/inbound-email-webhook

Conversation

@mpge
Copy link
Copy Markdown
Member

@mpge mpge commented Apr 24, 2026

Summary

Greenfield inbound-email foundation for .NET. Introduces:

  • InboundMessage — transport-agnostic record capturing a normalized inbound email (from, to, subject, bodies, threading headers, attachments).
  • IInboundEmailParser — pluggable interface for provider-specific webhook payload normalization (Postmark, Mailgun, SES, IMAP).
  • InboundEmailRouter — the resolution brain that maps an inbound message to an existing ticket via canonical Message-ID parsing + signed Reply-To verification.

Resolution order

Mirrors the NestJS reference and the per-framework inbound-verify PRs (Laravel #70, Rails #45, Django #42, Adonis #50, WordPress #33):

  1. In-Reply-To parsed via MessageIdUtil.ParseTicketIdFromMessageId — cold-start path, no DB lookup on the header required.
  2. References parsed via MessageIdUtil, each id in order.
  3. Signed Reply-To on ToEmail (reply+{id}.{hmac8}@...) verified via MessageIdUtil.VerifyReplyTo. Forged signatures rejected with CryptographicOperations.FixedTimeEquals under the hood.
  4. Subject line reference tag ([{Prefix}-\d+]) — legacy.
  5. Legacy InboundEmail.MessageId audit-log lookup — covers pre-migration Message-IDs we sent before the canonical format was in place.

Scope

Router + DTO + interface only. Follow-up PRs:

  1. PostmarkInboundParser / MailgunInboundParser / SesInboundParser implementations
  2. InboundEmailControllerPOST /escalated/webhook/email/inbound with signature verification
  3. InboundEmailService — orchestrates parser → router → reply/ticket create + attachment handling

Dependencies

Test plan

  • 10 xUnit tests use in-memory EF Core + real EscalatedOptions to exercise the full chain:
    • Canonical In-Reply-To parse
    • References with mixed unrelated ids
    • Signed Reply-To round-trip
    • Forgery rejection (wrong secret)
    • Secret-blank skip path
    • Subject-tag match
    • Legacy fallback via InboundEmail.MessageId
    • No-match null return
    • CandidateHeaderMessageIds helper: in-reply-to first, references after
    • CandidateHeaderMessageIds helper: empty headers yield none
  • CI green (won't trigger against stacked base until rebased)

…dEmailRouter

Greenfield inbound-email foundation for .NET. Transport-agnostic DTO
(InboundMessage), pluggable provider parser interface
(IInboundEmailParser), and the routing brain (InboundEmailRouter)
that resolves an inbound email to an existing ticket via canonical
Message-ID parsing + signed Reply-To verification.

Mirrors the NestJS reference and the per-framework inbound-verify
PRs (Laravel #70, Rails #45, Django #42, Adonis #50, WordPress #33).

Resolution order (first match wins):
  1. In-Reply-To parsed via MessageIdUtil — cold-start path.
  2. References parsed via MessageIdUtil, each id in order.
  3. Signed Reply-To on ToEmail verified via MessageIdUtil.
     Forged signatures are rejected with a timing-safe
     CryptographicOperations.FixedTimeEquals under the hood.
  4. Subject line reference tag [{Prefix}-\d+].
  5. Legacy InboundEmail.MessageId audit-log lookup.

10 xUnit tests use in-memory EF Core + real EscalatedOptions to
exercise the full chain end-to-end: canonical parse, References with
mixed unrelated ids, signed Reply-To round-trip, forgery rejection,
secret-blank skip, subject-tag match, legacy fallback, no-match
return, and the CandidateHeaderMessageIds helper.

Follow-up PRs (not in this commit):
  - PostmarkInboundParser / MailgunInboundParser / SesInboundParser
  - InboundEmailController (POST /escalated/webhook/email/inbound)
  - InboundEmailService (threading process() logic, attachment handling)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant