Skip to content

feat(inbound): InboundEmailService orchestrates reply/create + wire into controller#26

Open
mpge wants to merge 2 commits intofeat/mailgun-inbound-parserfrom
feat/inbound-email-orchestration
Open

feat(inbound): InboundEmailService orchestrates reply/create + wire into controller#26
mpge wants to merge 2 commits intofeat/mailgun-inbound-parserfrom
feat/inbound-email-orchestration

Conversation

@mpge
Copy link
Copy Markdown
Member

@mpge mpge commented Apr 24, 2026

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)

parser → InboundMessage
  → InboundEmailService.ProcessAsync(message, inboundEmail)
    → InboundEmailRouter.ResolveTicketAsync
      - ticket found  → TicketService.AddReplyAsync(ticket, body, "inbound_email")
                         inboundEmail.Status = "replied"
      - none + noise  → skipped (SNS confirmation, empty body+subject)
                         inboundEmail.Status = "skipped"
      - none + content→ TicketService.CreateAsync(subject, body, guest={name,email})
                         inboundEmail.Status = "created"

ProcessResult

  • 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 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

Test plan

  • Unit tests for InboundEmailService.ProcessAsync (reply path, create path, skip path, attachment pass-through) — in the next iteration
  • CI green (won't trigger against stacked base until rebased)

mpge added 2 commits April 24, 2026 05:46
…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.
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