Skip to content

feat: G18 — message mocking (email + SMS) & protocol-aware stub UX - #191

Merged
omercelikdev merged 13 commits into
mainfrom
feat/g18-message-mocking
Jul 26, 2026
Merged

feat: G18 — message mocking (email + SMS) & protocol-aware stub UX#191
omercelikdev merged 13 commits into
mainfrom
feat/g18-message-mocking

Conversation

@omercelikdev

Copy link
Copy Markdown
Owner

Implements the full G18 epic (#183): Mockifyr becomes a message capture platform as well as an API mock.

Closes #184, closes #185, closes #186, closes #187, closes #188, closes #189, closes #190.

Verticals

  • G18-pre — computed protocol field, badges + facet, channel-aware Add flow, descriptor admin + hot reload, WS mappings in the UI (+3 review passes: lossless GraphQL editing unified into the classic editor, standard JSON fields, pilled channel picker, segmented filter bar)
  • G18aMessageEnvelope + tenant-scoped bounded inbox + /__admin/messages (Stryker 100%)
  • G18b--smtp-port ESMTP capture facade, AUTH-as-tenant (real MailKit self-tests)
  • G18c — dashboard Messages inbox (sandboxed HTML preview, attachment download)
  • G18d--sms-profile twilio (official Twilio SDK self-test, stub-wins peek) + SMS thread view with OTP badges; fixes dotted-path serving (proven against the WireMock oracle)
  • G18e — SMTP faults/delay, simulated provider errors, capture webhook, --message-limit
  • G18fmatches regex verify filter + one-call OTP extraction

Validation

272 unit + 189 differential tests green (Docker oracle included); Stryker 100% on message logic (~230 mutants), SmtpSession 86.6% with survivors analyzed as equivalent; UI verified in-browser throughout. ADR 0009/0010 + roadmap + parity docs included.

Everything opt-in — no flag, no listener, no behavior change.

🤖 Generated with Claude Code

omercelikdev and others added 13 commits July 26, 2026 04:23
…b UX (ADR 0010)

The G18 roadmap group: email (SMTP capture) + SMS (provider profiles) as
first-class, tenant-scoped message channels — protocol mock + capture/
verify in one tool — prefixed by the protocol-aware stub UX the dashboard
needs before new channels arrive. Also completes the ADR index (0007/0008
were missing).

Refs #183

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…admin, channel editors

G18-pre (ADR 0010): the dashboard finally shows what a stub speaks.

Server:
- GET /__admin/mappings stamps a computed, never-stored `protocol`
  (graphql via the graphql-body-matcher custom matcher; grpc via a
  descriptor lookup through IStubProtocolProbe, implemented in the
  composition root so facades stay unacquainted; else http)
- /__admin/grpc/descriptors (GET/POST/DELETE): manage <root-dir>/grpc/
  *.dsc with parse-validation before write and hot-reload of the now-
  swappable ProtoDescriptors index — an upload serves gRPC with no
  restart, proven by a real SayHello call in the self-test
- GET/DELETE /__admin/message-mappings: list registrations as posted
  (id stamped) and delete, tenant-scoped

UI:
- protocol chips + a protocol facet on the stub tree (HTTP unmarked)
- New-stub flow opens with a channel choice: HTTP (classic editor,
  unchanged) · gRPC (service/method from loaded descriptors) · GraphQL
  (query/variables/operationName → exact matcher JSON) · WebSocket
  (trigger + reply → message-mapping), each with a live editable JSON
- WebSocket mappings listed under the tree with a read-only detail sheet
- Settings: gRPC descriptors card (upload/list/delete)
- all six locales translated

Tests: G18PreProtocolUxTests (6 wire self-tests, host-only) +
G18PreStubProtocolsTests (8 unit); full unit suite green; verified
in-browser against a live host. docs/parity/g18-messages.md records the
learned persistence re-serialization caveat.

Closes #184. Refs #183

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…messages

G18a (ADR 0009): the capture side of message mocking.

- Core: MessageEnvelope (email|sms, flat Meta for provider fields,
  attachments) + IMessageStore / IMessageSink — pure, zero deps
- Stores.InMemory: bounded per-tenant inbox (default 1000, oldest
  evicted first, newest-first reads) + the default store sink
- Application: Get/Count/Get-one/Delete/Reset via Mediant, with one
  MessageFilter shared by list and count (channel, any-recipient
  case-insensitive substring, contains over subject+bodies, limit
  where 0 = unlimited)
- Facade.Admin: /__admin/messages REST (list/count/get/delete/reset);
  attachment content is listed (name/type/size), not inlined

Tests: 21 unit/CQRS behaviors incl. tenant-isolation invariants +
1 wire test over a real Kestrel host. Mutation-tested with Stryker.NET
(local tool, new manifest): 100% on MessageOperations+Handlers (36),
InMemoryMessageStore (19) and StubProtocols (19); survivors killed by
new tests and one equivalent-mutant refactor (RemoveRange→RemoveAt).

Closes #185. Refs #183

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nbox

G18b (ADR 0009): Mockifyr.Facade.Smtp, a loopback ESMTP listener behind
--smtp-port (no flag, no listener). EHLO/HELO, MAIL, RCPT, DATA with
RFC 5321 dot-unstuffing, RSET, NOOP, QUIT; AUTH PLAIN/LOGIN accepted-
but-unchecked — the AUTH username names the tenant, the SMTP analog of
X-Mockifyr-Tenant. MimeKit (facade-edge only) parses DATA into a
MessageEnvelope: envelope recipients as To (header To: kept in Meta),
header From preferred (MAIL FROM kept as Meta.envelopeFrom), one
transport-added trailing newline trimmed, unparseable MIME still
captured raw. The session state machine is socket-free and unit-tested.

Validation (no oracle — WireMock has no SMTP): 5 real-client MailKit
self-tests against a full host (plain, HTML+attachment, multi-recipient,
two-mails-one-connection, dot-stuffing, AUTH-as-tenant) asserted via
/__admin/messages; 21 SmtpSession unit tests; Stryker 86.6% with the 11
survivors individually analyzed as equivalent mutants of the lenient
parser (documented in docs/parity/g18-messages.md).

Closes #186. Refs #183

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tachment download

G18c (ADR 0009): a Messages destination in the Mocking nav group —
captured traffic, not stubs.

- inbox list: channel chips (All/Email/SMS with counts), text search,
  5s auto-refresh, per-row delete + clear-all with confirm
- detail: subject/from/envelope-recipients/received header, attachment
  chips (download via the new GET /__admin/messages/{id}/attachments/
  {index}, served with the stored content type + file name), and
  Preview / Text / Details tabs
- the HTML preview renders inside <iframe sandbox=""> — captured mail
  is untrusted input: no scripts, no navigation, no same-origin
- all six locales translated

Verified in-browser against a live --smtp-port host with three real
mails (plain OTP, HTML + PDF attachment to two recipients, welcome):
list, filters, detail, sandboxed HTML render and the attachment
download headers all confirmed.

Closes #187. Refs #183

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… serving

G18d (ADR 0009): SMS mocking as a provider profile — SMS has no wire
protocol of its own, so --sms-profile twilio emulates the provider's
HTTP API on the existing facade.

- Mockifyr.Providers.Sms: POST /2010-04-01/Accounts/{sid}/Messages.json
  parses the form into an SMS MessageEnvelope (captured, provider fields
  in Meta) and answers a Twilio-shaped resource; missing fields answer
  Twilio's real error codes in Twilio's validation order
- a hand-written stub on the same URL still wins: the middleware peeks
  the engine on a buffered body and steps aside on a match — enabling
  the profile can never change existing serving
- fix(http): the mock-serving fallback used MapFallback's default
  {*path:nonfile} pattern, silently 404ing every stub whose last path
  segment looks like a file (/report.json). Found by this vertical,
  switched to an explicit {*path}, and proven against the real WireMock
  oracle (G18dDottedPathServingTests, Docker)
- UI: the SMS filter becomes a thread view — one row per recipient
  number, phone-style conversation, click-to-copy OTP badges
  (\b\d{4,8}\b); six locales (incl. the ja messages group that the
  earlier locale pass had skipped)

Validation: the official Twilio C# SDK pointed at Mockifyr creates a
message and parses our response; 7 wire self-tests + 3 oracle
differentials green; SMS flow verified in-browser (two threads, OTP
badges extracted).

Closes #188. Refs #183

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…inbox bound

G18e (ADR 0009): message channels learn to misbehave on purpose — the
message analog of HTTP delay/fault, as per-tenant facade directives.

- MessageBehaviors + IMessageBehaviorStore (Core, tenant-scoped) with
  GET/PUT/DELETE /__admin/messages/behaviors (CQRS; validation refuses
  negative delays and non-five-digit provider codes)
- SMTP: reject → 550 at the DATA gate (a real bounce, nothing
  captured), drop → connection closed mid-transaction, smtpDelayMs →
  the DATA acknowledgment is held after the payload is safe
- Twilio profile: smsErrorCode short-circuits with the simulated
  provider error before validation and capture
- capture webhook: webhookUrl POSTs every captured message as JSON —
  fire-and-forget NotifyingMessageSink decorator in the composition
  root; a webhook can never slow or fail a capture
- --message-limit <n> bounds the per-tenant inbox

Validation: 7 real-client wire tests (MailKit feels each fault; the
webhook loop is asserted capture → hook → same-host stub → journal;
--message-limit keeps the newest two) + 7 handler unit tests; Stryker
100% on message operations/handlers (50 mutants).

Closes #189. Refs #183

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…payoff

G18f (ADR 0009): the reason the whole group exists — an e2e test reads
an OTP in one admin call.

- matches: a regex filter on /__admin/messages list + count (250ms
  budget; a malformed or catastrophic pattern filters to nothing — the
  admin surface never 500s over an input)
- GET /__admin/messages/{id}/otp and the e2e shape
  GET /__admin/messages/otp?recipient=…&channel=…&pattern=… — newest
  matching message wins, default pattern \b\d{4,8}\b, body → subject →
  HTML search order, a participating capture group beats the full match
- honest failures: Message.NotFound / Otp.NoMatch / Otp.InvalidPattern
  (422)

Validation: the epic's end-to-end proof — a real SMTP mail (MailKit)
and a Twilio-profile SMS each read back with one GET, latest-wins
verified against an older code; 4 wire + 9 unit tests; Stryker 100%
on message operations/handlers (67 mutants).

Closes #190. Refs #183

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two new projects (Facade.Smtp, Providers.Sms) join the map; §7 records
the completed G18 message-mocking group and its validation story.

Refs #183

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found in review: the classic Form editor rebuilds the mapping from its
own model on save, and that model has no customMatcher representation —
editing a GraphQL stub in the Form tab silently dropped the
graphql-body-matcher, turning the stub into a match-all.

- a stub whose request carries a customMatcher now opens JSON-only:
  the Form tab is disabled with an explanatory hint, and the JSON tab
  (which always showed the exact mapping) is forced
- gRPC stubs stay form-editable — urlPath + equalToJson express them
  fully, nothing is lost on a form save
- the editor header and the workspace tab labels now show the protocol
  chip, so an open tab reads as gRPC/GraphQL at a glance
- editor.jsonOnly hint translated in all six locales

Verified in-browser: the GraphQL stub opens JSON-only (Form disabled,
matcher intact in the JSON), the gRPC stub opens with the Form active,
enabled and fully seeded.

Refs #184, #183

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cker

Review feedback on G18-pre's edit path:

- a GraphQL stub now edits in the same channel form New uses — query,
  variables, operationName and the response seeded from the mapping —
  instead of a disabled Form tab. Saving MERGES onto the original
  mapping, so name/priority/anything the form doesn't model survive;
  PUT keeps the stub id. The JSON-only lock remains for unknown custom
  matchers (extensions), which no form can express
- found while wiring it: the list response's computed protocol field
  (ADR 0010) was reaching Stub.raw, so a JSON-tab re-save could have
  persisted it into the stored Source — the projection now strips it
- the Channel picker is restyled to the design system's pilled-tabs
  segmented control (Tabs primitives) — one visual language with the
  Form/JSON switcher instead of a one-off chip row

Verified in-browser end to end: Hero query opens seeded, Save keeps
the matcher, the name and jsonBody, and the persisted Source on disk
carries no protocol key; the picker renders as pills with HTTP active.

Refs #184, #183

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ndard JSON fields

Review feedback, third pass — one editor, every feature, every channel:

- graphqlQuery/Variables/OperationName join the stub form model and
  emit the exact graphql-body-matcher JSON, so GraphQL stubs use the
  FULL classic editor (webhooks, delay/fault, scenarios, priority,
  headers, Form+JSON tabs) in New and Edit — the separate GraphQL
  channel form is gone, and the form is no longer lossy
- jsonBody preservation: a form save now keeps a jsonBody response as
  jsonBody (previously it silently became a body string — which would
  also have corrupted gRPC stubs edited in the form)
- the JSON-only lock now applies only to unknown (extension) custom
  matchers, which no form can express
- gRPC channel form's request/response use the standard framed JSON
  editor (lint, Beautify, Copy) instead of bare textareas
- channel picker pills are the standard TabsList size (same 32px as
  Form/JSON); the facet row (Method/Status/Protocol) aligns flush with
  the search box edges, wrapping on narrow panels

Verified in-browser: GraphQL edit save keeps matcher+name+jsonBody;
New GraphQL seeds query/urlPath with the full feature set visible;
gRPC form shows three framed JSON editors with Beautify; pill heights
measured equal; facet row edge-aligned.

Refs #184, #183

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review feedback: three floating filter buttons with uneven gaps (and a
wrap on narrow panels) read as clutter. Method/Status/Protocol are now
one connected segmented group — three equal cells sharing inner
borders, rounded only at the outer ends — flush with the search box on
both edges. The trigger icon is shrink-proof (a squeezed cell no longer
shrinks it), and FacetFilter accepts a className for per-position
styling.

Verified in-browser: both edges aligned, single row, equal cells,
14px icons across all three.

Refs #184, #183

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@omercelikdev
omercelikdev merged commit 3183702 into main Jul 26, 2026
3 checks passed
@omercelikdev
omercelikdev deleted the feat/g18-message-mocking branch July 26, 2026 11:25
omercelikdev added a commit that referenced this pull request Jul 26, 2026
…ocol-aware journal (#196)

* feat(ui): Messages screen — outbound positioning, quick-start, channel behaviors

Messages-screen polish (post-#191), on the approved plan:

- positioning: the inbox holds OUTBOUND traffic (what the app under
  test sent); Mockifyr answers like the real SMTP server / SMS provider
  and delivers nothing. A subtitle states it; the empty state is a
  quick-start with copyable one-liners (--smtp-port 1025 /
  --sms-profile twilio + how to point an app) and the one-call OTP
  verify hint
- inbox stays master-detail (journal family, not stubs tabs — the
  agreed model) and gains date-group headers in the list
- NEW — Channel behaviors sheet, the UI for G18e's
  /__admin/messages/behaviors: SMTP fault (none/reject/drop) + DATA
  delay, simulated SMS provider error code, capture webhook URL, with
  reset-to-defaults; server-side validation surfaces (negative delay /
  bad code → error toast)

Verified in-browser: subtitle + date groups render; the behaviors
sheet round-trips through the admin API (PUT 200, GET reflects, invalid
422, DELETE resets); the quick-start shows when the inbox is empty.
All six locales translated.

Refs #183

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(ui): Messages adopts the Journal page shape — one language for traffic

Design decision, confirmed in review: EDITED things (Stubs) use the
workspace/tabs layout; TRAFFIC (Journal, Messages) uses the page +
card + sortable table + detail-sheet layout. Messages now mirrors the
Journal exactly:

- channel switcher as the Journal's pill (All/Email/SMS with counts)
- page header with the outbound-capture positioning line
- one card: toolbar (filter, Channel behaviors, Refresh, Density,
  Clear inbox) + sortable columns (Channel/To/Content/From/Received
  with full timestamp + relative time) + Showing-x-of-y footer with
  pagination
- row click opens a detail sheet: mail = attachments + sandboxed
  Preview/Text/Details tabs; SMS = the recipient's conversation thread
  with click-to-copy OTP badges; OTP also badges inline in the Content
  column
- quick-start renders inside the card when the inbox is empty;
  master-detail split panes are gone

Verified in-browser: table renders both channels with sort/pagination,
mail sheet shows tabs, SMS sheet shows the thread + OTP 775533 badge.

Refs #183

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(journal): protocol classification — gRPC/GraphQL/SMS entries read as what they are

Review feedback: the stub list learned its protocol (ADR 0010) but the
journal still showed every entry as bare HTTP — Twilio-profile sends
read as mystery unmatched POSTs, gRPC calls as plain routes.

- GET /__admin/requests stamps a computed protocol per entry: sms by
  the provider path, grpc via the descriptor probe, graphql via the
  matched stub's custom matcher (an unmatched GraphQL-shaped post
  honestly stays http — there is no stub to inspect). Same decision
  table as the stub list, computed at query time, never stored
- journal UI: protocol chips in the URL column (HTTP stays unmarked,
  the stub-tree rule) + a Protocol facet beside Method/Status

Proven end to end (G18JournalProtocolTests): real traffic driven over
all four channels — a gRPC call with a real client, a GraphQL post, a
Twilio-profile send, plain HTTP — each classified correctly in the
journal; verified in-browser with chips + facet rendering.

Refs #183, #184

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(messages): Mailpit-style raw source, sidebar badge; standard WS JSON field; env spacing

Review feedback, in one pass:

- raw source capture (#194): MessageEnvelope carries the wire payload
  as received — SMTP keeps the full MIME text (headers, boundaries,
  every part, byte-for-byte), the Twilio profile keeps the provider
  form body. GET /__admin/messages/{id} returns it; the mail detail
  sheet gains a Source tab and each SMS bubble a source toggle, both
  fetched on demand so the list stays lean. Proven end to end: a
  multipart mail's raw shows the multipart header, both parts and the
  HTML intact; the SMS raw is the urlencoded body verbatim
- the sidebar Messages item now badges the captured count like
  Stubs/Journal (#194)
- the WebSocket channel form's reply uses the framed JSON editor
  (lint relaxed — a reply may be plain text) instead of a bare
  textarea (#193)
- Environments header/example copy breathes (leading-relaxed) (#192)

Verified in-browser: badge renders, Source tab shows the real MIME,
SMS source toggle works; G18 suite (35) + Application (180) green.

Closes #192, closes #193, closes #194. Refs #183

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* polish(messages): copy actions across every detail tab; styled-template proof

Review feedback: the detail sheet's panes should all offer the standard
niceties.

- Preview: Copy HTML button (the visual pane's escape hatch); the
  iframe now fills the pane height
- Text: copy the body
- Source: copy the raw wire payload — deliberately NOT beautified, the
  pane's promise is byte-for-byte truth
- Details: the id and every meta value copy on click (they travel into
  tests constantly)
- copyHtml translated in all six locales

Proven with a real-world styled template: a gradient-header,
inline-CSS order-confirmation mail (table layout, dashed OTP box, CTA
button) sent over real SMTP renders pixel-faithful in the sandboxed
preview; all four tabs verified in-browser with their copy actions.

Refs #194, #183

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* polish(messages): hover-copy on the sheet header; Details pane breathes

Review feedback:

- the subject line and the from→to address line in the detail sheet
  header now copy on click, with the copy icon appearing on hover —
  both travel into tests and bug reports constantly
- the Details pane no longer hugs the tab bar: it opens with the same
  rhythm as the other panes and its rows gained real spacing
  (gap-y-3 / gap-x-6)

Verified in-browser: header renders as two copy controls, Details rows
aligned and readable.

Refs #194, #183

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(messages): bulk-recipient rows no longer explode the table

Found in the pre-merge edge sweep: a 25-recipient mail rendered its
whole address list in the To column, blowing the row up to 550px. The
column now shows the first address (truncated) + a "+N" count chip;
the full list stays in the detail sheet header.

Edge sweep also verified: an XSS-bearing HTML mail cannot escape the
sandboxed preview (no navigation, no script), a subjectless/empty mail
opens as "(no subject)", a 300KB body with a unicode attachment name
renders, a 12-digit number does NOT false-badge as OTP while a real
6-digit code does. Full suites green: differential 190/190 (oracle
included), unit 272/272.

Refs #183

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment