Skip to content

test(inbound): parser equivalence across Postmark / Mailgun / SES (NestJS)#22

Merged
mpge merged 26 commits intomainfrom
test/parser-equivalence
Apr 27, 2026
Merged

test(inbound): parser equivalence across Postmark / Mailgun / SES (NestJS)#22
mpge merged 26 commits intomainfrom
test/parser-equivalence

Conversation

@mpge
Copy link
Copy Markdown
Member

@mpge mpge commented Apr 24, 2026

Summary

Closes the parser-equivalence test rollout across all 6 frameworks — NestJS reference plus the 5 greenfield ports:

Framework PR
escalated-go #37
escalated-dotnet #31
escalated-spring #34
escalated-phoenix #43
escalated-symfony #39
escalated-nestjs this PR

Tests

Two Jest cases:

  • `normalizes to the same threading metadata`: `from` / `to` / `subject` / `inReplyTo` / `references` match across all three parsers for a single logical reply. Failure messages are tagged with the offending parser so a regression points at the right file.
  • `produces the same body text`: Postmark + Mailgun forward directly; SES decodes from base64 MIME (test uses `contains` since SES may include a trailing newline after MIME decode).

Shared `LogicalEmail` interface + `*Payload` builders mean adding a fourth provider validates against the existing three for free.

Full suite: 269/269 tests pass (up from 267).

Stacked PR

Based on `feat/attachment-downloader` (#21). Merge order: #17#18#19#20#21 → this PR.

@mpge mpge changed the base branch from feat/attachment-downloader to main April 27, 2026 02:56
mpge added 25 commits April 26, 2026 22:57
Adds @nestjs-modules/mailer, nodemailer, handlebars runtime deps
and @types/nodemailer for type support.

Phase 0 / Task 0.1 of the public ticket system plan.
Fills the gap noted in the audit (no factories existed). Used by
spec files across the public ticket feature work.

Phase 0 / Task 0.3 of the public ticket system plan.
First-class identity for ticket requesters who may not have a host-app
user account. Unique email index enables dedupe of repeat guests.
Nullable userId allows linking to a host-app user later (see
ContactService.promoteToUser in a follow-up commit).

Phase 1 / Task 1.1 of the public ticket system plan.
Phase 1 / Task 1.2 of the public ticket system plan.
Provides:
- findOrCreateByEmail(email, name?) — case-insensitive dedupe, trims
  whitespace, fills in blank existing name but never overwrites a
  non-blank one.
- linkToUser(contactId, userId) — simple linkage.
- promoteToUser(contactId, userId) — linkage + back-stamps
  ticket.requesterId on all tickets carrying the contactId.
- findByEmail / findById — normalized lookups.

Phase 1 / Tasks 1.4 + 1.5 of the public ticket system plan.
POST /escalated/widget/tickets now supports two paths:
  1. Public — body.email (+optional name). Contact is resolved via
     ContactService.findOrCreateByEmail and linked to the ticket.
     requesterId is derived from the configured guestPolicy:
       - unassigned → 0
       - guest_user → options.guestPolicy.guestUserId
       - prompt_signup → 0 + TicketSignupInviteEvent emitted
  2. Legacy — body.requesterId (host-app authenticated user flow,
     unchanged). No Contact resolution.
  Missing both raises BadRequestException.

Adds ESCALATED_EVENTS.SIGNUP_INVITE + TicketSignupInviteEvent for the
signup-invite email listener (built in Phase 4).

Phase 2 / Tasks 2.2 + 2.4 of the public ticket system plan.
Adds PublicSubmitThrottleGuard with an in-memory sliding-window counter
(10 submissions per hour per normalized email). Pass-through when
body.email is absent, so the legacy requesterId path defers to the
module-level ThrottlerGuard.

Breach returns 429. Same-email case variants share a bucket. Timestamps
older than the window are evicted lazily on next check.

Known limitation: in-memory store scales to a single instance only;
multi-instance deployments should swap for a Redis-backed store. Noted
in source comment for a later phase.

Phase 2 / Task 2.5 of the public ticket system plan.
…orkflowEngineService

These were defined but never registered with TypeORM or exported from
the module. Prerequisite for wiring WorkflowExecutor in a follow-up.

Phase 3 / Task 3.0 of the public ticket system plan.
Implements the side-effecting half of the workflow system (distinct
from WorkflowEngineService which only evaluates conditions).

Actions shipped in this commit:
  - change_priority
  - add_tag      (by slug, falls back to numeric id)
  - remove_tag   (same resolution)
  - change_status (by slug, falls back to id; emits TICKET_STATUS_CHANGED)
  - set_department
  - assign_agent (writes activity log + emits TICKET_ASSIGNED)
  - add_note     (internal reply)

Deferred to follow-ups: send_webhook, add_follower, delay,
assign_round_robin, send_notification, set_type.

Not yet wired to events — WorkflowListener comes next.

Phase 3 / Tasks 3.1-3.6 of the public ticket system plan.
…istener

Routing rules are now live. TicketCreatedEvent (and friends) fire a
chain:
  listener → runner.runForEvent(triggerEvent, ticket)
    → workflowRepo.find({triggerEvent, isActive}) ASC position
    → for each: engine.evaluateConditions → log row → executor.execute
    → honor stopOnMatch, catch+log executor errors

Listener mappings:
  TICKET_CREATED        → ticket.created
  TICKET_UPDATED        → ticket.updated
  TICKET_ASSIGNED       → ticket.assigned
  TICKET_STATUS_CHANGED → ticket.status_changed
  TICKET_REPLY_CREATED  → reply.created

WorkflowLog rows capture: matched condition, actions run, error message,
start/complete timestamps — sufficient for the existing Logs.vue UI.

Tests: 177 passing (+25 since Phase 2 — runner spec 6, listener spec 5,
executor spec 14).

Phase 3 / Task 3.11 of the public ticket system plan.
Proves the full chain wire-up: TicketService.create emits
TICKET_CREATED → WorkflowListener fires → WorkflowRunnerService loads
active workflows for ticket.created → conditions evaluated →
executor.execute called with matching actions → WorkflowLog row
written.

Covers: match → executes, no workflows → no-op, conditions fail → log
row without execution.

Uses real EventEmitterModule (not mocked) with mocked repos and a
mocked executor for observation. 3 tests, 180 total passing.

Phase 3 / Task 3.12 of the public ticket system plan.
Templates rendered inline (HTML + plain text):
  - renderTicketCreated: guest confirmation with reference number +
    subject + body + optional portal link
  - renderReplyPosted: agent reply notification (quotes the reply,
    threads via In-Reply-To header)
  - renderSignupInvite: prompt-signup flow CTA

EmailService methods:
  - sendTicketCreated(to, ticket, contact, guestAccessToken)
  - sendReplyPosted(to, ticket, reply, contact, guestAccessToken)
  - sendSignupInvite(to, ticket, contact)

Every outbound message sets:
  - Message-ID: <ticket-{id}[-reply-{replyId}]@{replyDomain}>
  - X-Escalated-Ticket-Id: {id}
  - Reply-To: reply+{id}.{hmac8}@{replyDomain}   (for inbound routing)

Reply emails also set In-Reply-To + References → ticket's initial
Message-ID, so MUAs thread correctly.

No-ops silently when MailerService or options.mail is absent — the
module must boot without a mail transport configured.

Phase 4 / Task 4.3 of the public ticket system plan.
EmailListener subscribes to:
  - TICKET_CREATED      → sendTicketCreated (when ticket.contactId set)
  - TICKET_REPLY_CREATED → sendReplyPosted (external replies only, not internal notes)
  - SIGNUP_INVITE       → sendSignupInvite (only fires under prompt_signup policy)

All handlers catch EmailService errors and warn-log — a mail outage
never blocks ticket creation or reply posting (Phase 4 / Task 4.7).

MailerModule.forRoot is registered conditionally — only when
options.mail is present. Host apps that don't configure email still
boot; EmailService no-ops silently.

Tests: 205 passing (+25 in Phase 4 — message-id 11, email service 6,
listener 8).

Phase 4 / Tasks 4.1 + 4.4-4.7 of the public ticket system plan.
Captures every inbound webhook call with raw payload + parse summary
+ route outcome for diagnostics. Registered with TypeORM.

Phase 5 / Task 5.1 of the public ticket system plan.
Provider-agnostic ParsedInboundEmail shape:
  { from, fromName, to, subject, textBody, htmlBody, messageId,
    inReplyTo, references[] }

Postmark adapter pulls Message-ID / In-Reply-To / References from the
provider's Headers array (case-insensitive), handles missing fields,
falls back to FromFull.Email when flat From is absent.

Keeps the shape pluggable so Mailgun / SendGrid adapters can drop in
later (Phase 5b).

Phase 5 / Task 5.2 of the public ticket system plan.
Priority order for matching inbound email → existing ticket:
  1. In-Reply-To header contains <ticket-{id}...@{domain}>
  1b. References chain contains same
  2. Envelope To is our signed reply+{id}.{hmac}@{replyDomain} address
  3. Subject contains [TK-XXX] reference number

If none match, creates a new ticket via existing TicketService.create
path (reusing Contact dedupe by sender email). Missing sender → ignored.

All branches return a structured InboundRouteResult with matched/
created ids and error info for the audit log row.

Phase 5 / Task 5.3 of the public ticket system plan.
POST /escalated/webhook/email/inbound accepts Postmark inbound webhook
payloads and routes them via InboundRouterService (Phase 5 / 5.3):
  - reply_added: existing ticket found via threading headers or
    subject reference
  - ticket_created: new ticket from sender's Contact
  - ignored: malformed
  - error: routing failure (audited)

Every request produces an InboundEmail audit row.

Signature guard (InboundWebhookSignatureGuard): constant-time compare
of X-Escalated-Inbound-Secret header against options.inbound.webhookSecret.
If secret is not configured, every request is rejected — prevents
accidental exposure.

Host app wires Postmark with Basic Auth → proxy adds the header.

Tests: 228 passing (+23 in Phase 5), 0 lint errors.

Phase 5 / Task 5.4 of the public ticket system plan.
…on fallback

Widget controller now calls SettingsService.getTyped('guest_policy', null)
first, falling back to options.guestPolicy. This means admins can change
guest policy at runtime via PUT /escalated/admin/settings (existing
endpoint, payload: { key: 'guest_policy', type: 'json', value: { ... } })
without a redeploy.

Module option remains the default for deployments that don't surface
the settings UI.

Phase 6 / Task 6.2 of the public ticket system plan.
…ation

New action type shared between Workflows and Macros (Phase 7). Takes a
template string with {{field}} placeholders, interpolates against the
ticket via WorkflowEngineService.interpolateVariables, inserts as an
external-visible reply.

Example: 'Hi! About {{subject}} (priority: {{priority}}) — on it.'

Phase 7 (Macros) reduced per the audit correction: Macro entity +
service + controllers already exist. This action type gives macros a
canned-reply capability that reuses WorkflowExecutor.
@nestjs-modules/mailer has lodash as a peer dependency but doesn't
declare it in its peerDependencies — our local install resolved it
transitively, but clean CI installs fail with:

  Cannot find module 'lodash' from
  'node_modules/@nestjs-modules/mailer/dist/mailer.service.js'

Adding lodash + @types/lodash explicitly. Fixes the CI test-(18/20/22)
failures on PR #17.
CI's 'Check Prettier formatting' step was failing on 24 files
added during Phases 0-9. Ran 'npm run format' (prettier --write)
to bring them in line with the project's code style.

No functional changes — pure whitespace / import-order / line-wrap
normalization.
Brings the NestJS reference up to 2-provider parity with the
greenfield ports (.NET / Spring / Go / Phoenix / Symfony). Before:
the reference only had a Postmark parser and the controller
hardcoded it. After: the controller picks the parser at request
time via options.inbound.provider.

MailgunInboundParser parses Mailgun's flat form-encoded body
shape: sender / from / recipient / subject / body-plain / body-html
/ Message-Id / In-Reply-To / References. Extracts the display name
from "Display Name <email@host>"-style from headers; strips
surrounding quotes.

InboundEmailController now injects both parsers and picks by
provider label:
  - "mailgun" → MailgunInboundParser
  - "postmark" (default) → PostmarkInboundParser

Module registers both parsers.

8 new Mailgun parser tests cover: happy path, threading headers
+ multi-id references, sender-fallback, bare-email no-display-name,
quoted display name, To-vs-recipient fallback, empty body
defaults, null payload.

Updated controller spec picks up the new MailgunInboundParser
dependency and adds a case asserting the provider switch routes
through the right parser.

Full suite: 240/240 tests pass.
Completes the 3-provider parity push on the NestJS reference (after
MailgunInboundParser in the previous commit). AWS SES receipt rules
publish to an SNS topic; host apps subscribe via HTTP and SNS POSTs
the envelope when options.inbound.provider is "ses".

Handles:
  - Type=SubscriptionConfirmation — throws
    SESSubscriptionConfirmationError (carrying topicArn +
    subscribeUrl + token). Host apps catch it to surface the
    URL they must GET out-of-band.
  - Type=Notification — extracts threading metadata from
    mail.commonHeaders, falls back to mail.headers array when a
    field is absent.
  - Best-effort MIME body extraction from the base64 content field:
    handles single-part text/plain, text/html, multipart/alternative,
    and quoted-printable + base64 transfer encoding. Hand-rolled
    splitter — no external MIME lib dep.

InboundEmailController.pickParser now recognises `'ses'` and routes
to SESInboundParser.

13 SES parser tests (subscription confirmation sentinel, threading
metadata extraction, plain body decode, multipart body decode,
missing-content fallback, headers-array threading fallback, unknown
envelope type, missing/malformed Message). Full suite: 250/250 tests
pass (up from 240).

Brings the NestJS reference fully up to parity with the 5 greenfield
ports (.NET / Spring / Go / Phoenix / Symfony) — each now supports
all three major inbound providers.
Ports the per-framework downloaders (escalated-go#35 /
escalated-dotnet#29 / escalated-spring#32 / escalated-phoenix#41 /
escalated-symfony#37) to the NestJS reference. Brings the reference
to full parity with what the ports ship.

AttachmentDownloader:
  - download(pending, ticketId, replyId?) — fetch + persist via an
    AttachmentStorage adapter + new Attachment row on the TypeORM
    repository.
  - downloadAll continues past per-attachment failures; returns a
    parallel AttachmentDownloadResult[] (persisted|error per input).
  - AttachmentDownloaderOptions: maxBytes size cap (throws
    AttachmentTooLargeError) + optional HTTP basic auth (Mailgun
    API-key URLs) + overridable `fetch` for tests.
  - safeFilename strips path separators so "../../etc/passwd" →
    "passwd" — crafted names can't escape the storage root.
  - Response Content-Type fallback when the pending record's
    contentType is blank.

LocalFileAttachmentStorage:
  - Reference AttachmentStorage that writes to local disk with
    timestamp + random-suffix filename prefixing to avoid
    collisions under concurrent writes.
  - Host apps with S3 / GCS / Azure implement AttachmentStorage
    themselves and bind via the ATTACHMENT_STORAGE DI token.

17 tests cover: happy path, reply-attached variant, HTTP errors,
oversize rejection, basic-auth header, missing-URL guard,
Content-Type fallback, safeFilename table, downloadAll
partial-failure batching, LocalFileStorage write / empty-root
rejection / unique path per call.

Full suite: 267/267 tests pass (up from 250).
…stJS)

Closes the parser-equivalence test rollout across all 6 frameworks:
NestJS reference, plus the 5 greenfield ports (#37 go, #31 dotnet,
#34 spring, #43 phoenix, #39 symfony).

Two Jest cases:
  - normalizes to the same threading metadata: from / to / subject /
    inReplyTo / references match across all three parsers for a
    single logical reply email. Failure messages are tagged with the
    offending parser so a regression points at the right file.
  - produces the same body text: Postmark + Mailgun forward
    directly; SES decodes from base64 MIME (test uses `contains`
    since SES may include a trailing newline).

Shared LogicalEmail interface + *Payload builders mean adding a
fourth provider validates against the existing three for free.

Full suite: 269/269 tests pass (up from 267).
@mpge mpge force-pushed the test/parser-equivalence branch from 0a9b349 to 538b70f Compare April 27, 2026 02:57
@mpge mpge merged commit 5718cf1 into main Apr 27, 2026
4 checks passed
@mpge mpge deleted the test/parser-equivalence branch April 27, 2026 03:01
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