feat(inbound): InboundEmailService orchestrates reply/create + wire into controller#26
Open
mpge wants to merge 2 commits intofeat/mailgun-inbound-parserfrom
Open
feat(inbound): InboundEmailService orchestrates reply/create + wire into controller#26mpge wants to merge 2 commits intofeat/mailgun-inbound-parserfrom
mpge wants to merge 2 commits intofeat/mailgun-inbound-parserfrom
Conversation
…nto controller
Closes the last missing piece of the inbound webhook loop for .NET:
the service that actually creates tickets + adds replies based on
router outcome.
Flow (per inbound POST):
parser → InboundMessage
→ InboundEmailService.ProcessAsync(message, inboundEmail)
→ InboundEmailRouter.ResolveTicketAsync
- ticket found: TicketService.AddReplyAsync(ticket, body, 'inbound_email')
→ inboundEmail.Status = 'replied'
- none: skip when noise (SNS confirmation, empty body+subject)
→ inboundEmail.Status = 'skipped'
- none + content: TicketService.CreateAsync(subject, body, guest={name,email})
→ inboundEmail.Status = 'created'
ProcessResult carries:
- Outcome (RepliedToExisting | CreatedNew | Skipped)
- TicketId / ReplyId
- PendingAttachmentDownloads — provider-hosted attachment URLs
(e.g. Mailgun) that a follow-up worker fetches + persists
out-of-band. Empty when all attachments came inline (Postmark).
Controller updated to return the richer response:
{ inboundId, status, outcome, ticketId, replyId,
pendingAttachmentDownloads }
Noise-filter catches SNS subscription confirmations
(no-reply@sns.amazonaws.com) and fully-empty emails so we don't
create bogus tickets from bounces.
9 xUnit tests exercise every branch of the orchestration: - ProcessAsync_ExistingTicketMatched_AddsReply: router hit → TicketService.AddReplyAsync → audit row status='replied', TicketId + ReplyId stamped, Reply row persisted. - ProcessAsync_NoMatchAndRealContent_CreatesNewTicket: router miss + body present → TicketService.CreateAsync → audit row status='created', new Ticket persisted with guest name/email from inbound. - ProcessAsync_NoSubjectFallsBackToPlaceholder: empty subject + non-empty body → ticket gets '(no subject)'. - ProcessAsync_SkipsSnsConfirmation: from=no-reply@sns → skipped, no Ticket created. - ProcessAsync_SkipsEmptyBodyAndSubject: empty body+subject → skipped. - ProcessAsync_PassesThroughPendingAttachmentDownloads: only provider-hosted (DownloadUrl without Content) attachments surface in PendingAttachmentDownloads; inline attachments are excluded. - IsNoiseEmail returns true for SNS confirmations. - IsNoiseEmail returns true for empty body+subject. - IsNoiseEmail returns false for real content. Uses in-memory EF Core + real TicketService so each test verifies actual DB state transitions, not just method calls.
This was referenced Apr 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the last missing piece of the inbound webhook loop for .NET: the orchestration service that actually creates tickets + adds replies based on router outcome.
Flow (per inbound POST)
ProcessResult
Outcome(RepliedToExisting|CreatedNew|Skipped)TicketId/ReplyIdPendingAttachmentDownloads— provider-hosted attachment URLs (e.g. Mailgun) that a follow-up worker fetches + persists out-of-band. Empty when all attachments came inline (Postmark).Controller response
Expanded from
{ inboundId, status, ticketId }to include the richer result:{ "inboundId": 123, "status": "replied", "outcome": "repliedtoexisting", "ticketId": 42, "replyId": 99, "pendingAttachmentDownloads": [ { "name": "report.pdf", "contentType": "application/pdf", "sizeBytes": 5120, "downloadUrl": "https://mailgun.example/att/abc" } ] }Noise filter
Catches SNS subscription confirmations (
no-reply@sns.amazonaws.com) and fully-empty emails so we don't create bogus tickets from bounces.Dependencies
feat/mailgun-inbound-parser). Merge order: feat(email): add MessageIdUtil for RFC 5322 threading + signed Reply-To #21 → feat(email): wire MessageIdUtil into EmailTemplates + signed Reply-To #22 → feat(inbound): scaffold InboundMessage + IInboundEmailParser + InboundEmailRouter #23 → feat(inbound): PostmarkInboundParser + InboundEmailController + DI wiring #24 → feat(inbound): MailgunInboundParser + DI registration #25 → this PR.Test plan
InboundEmailService.ProcessAsync(reply path, create path, skip path, attachment pass-through) — in the next iteration