feat: G18 — message mocking (email + SMS) & protocol-aware stub UX - #191
Merged
Conversation
…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>
This was referenced Jul 26, 2026
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>
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.
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
protocolfield, 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)MessageEnvelope+ tenant-scoped bounded inbox +/__admin/messages(Stryker 100%)--smtp-portESMTP capture facade, AUTH-as-tenant (real MailKit self-tests)--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)--message-limitmatchesregex verify filter + one-call OTP extractionValidation
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