Mail trails
A mailbox reads as conversations: one section per exchange, its messages inside, a count in the heading. This page is why it is built the way it is, and what the next person must not undo.
A trail is a VIEW, never a node
One node per message stays the source of truth. A stored trail node would have to be rewritten on every reply β turning an immutable fact into a mutable aggregate, and re-creating exactly the reconcile-that-writes-into-its-own-query shape that has produced write storms in this repo before. So the grouping is derived, every time, from what the reader is already allowed to see.
π¨ The grouping runs where the access check already ran
MailboxAreas hands the GUI a declared query plus a property name
(MailboxAreas.Trails β WithRenderMode(Grouped).WithGroupBy(ThreadKey)). The GUI runs the query,
checks every hit against the viewer, and buckets only the rows it kept.
Grouping server-side would be a mail leak. A mailbox is shared in the only sense that is safe β two people open the same page and each sees the messages they are entitled to see. Code here that read messages to sort them into trails and then rendered them would hand one reader another reader's mail. If a future change needs grouping the control cannot express, the fix is to extend the control, never to read and render messages in the area.
MailTrails is therefore pure: it is the model the Tests area asserts without a hub, and what a
renderer should use if it grows the capability. It is not on the render path for the shared list.
The key is derived, so there is nothing to backfill
EmailContent.ThreadKey is computed from Subject (like IsDraft), not stored as its own
truth:
- every message of one exchange carries the same value, because the same subject reduces the same way;
- existing mail groups correctly with no migration β the key is computed whenever the record is read or re-serialised;
- a message that is re-titled re-files itself, where a key written once would go stale silently.
MailTrails.NormalizeSubject strips the markers a client stacks β Re:, RE:, AW:, WG:,
Fwd:, FW:, the numbered Re[2]: / Re(3): forms, and a gateway's [External] banner β in any
order and any number, then collapses whitespace.
Two rules the tests pin, because getting either wrong loses mail:
- Only a leading marker followed by its separator is stripped. Rebuilding the pipeline and Review of the offer keep their first word; a contains-test would eat it.
- Different subjects never merge, and a message with no subject keeps its own trail rather than pooling with every other subject-less message into one invented conversation.
The key doubles as the section heading, which is why it stays the readable subject rather than a hash or a slug.
Both message types, one list
A mailbox asks for both message types in one nodeType: alternation
(EmailActions.AnyEmailType β Essentials/Email|Email):
| Type | Written by | ThreadKey |
|---|---|---|
Essentials/Email |
the Mailbox's own +, and the assistant's drafts | DERIVED from the subject |
Email (the platform's) |
the inbound pipeline, and every flow that FILES a real message | STORED, written on intake |
π¨ Asking for one of them showed drafts and nothing else. Every message actually recorded on the
mesh is the platform type β measured 2026-09-17, all 50 records under
Campaign/Notus/Correspondence were Email, and they appeared in no mailbox at all. Both types
expose a property named ThreadKey, which is what lets one grouping property serve both; the
renderer resolves it by name against each row the GUI already access-checked.
What it does not do yet
- Sections are ordered by the control (label, or size under
GroupByFrequency), not by latest activity. Ordering sections by recency is not somethingMeshSearchControlcan express; the recency ordering this view wants lives, tested, inMailTrails.Build, ready for a control that grows the option. Within a trail the messages are newest first, from the catalog's own sort. - A STORED key that is null buckets into one unnamed section. The renderer's fallback for an
empty group value is the node type, not the subject, so such messages would read as one giant
invented conversation. A per-property fallback belongs on
GroupingConfig, which lives in CORE β so that fix is a core change, not mail semantics hardcoded into a generic renderer. - The two normalizers spell the same conversation differently. Intake
(
EmailInboundProcessor.ThreadKey) slugifies and keeps the gateway banner; the derived key strips the banner and stays readable. So one exchange holding BOTH a recorded message and a draft shows as two sections. Within a single type it is consistent, which is why filed correspondence reads correctly. Unifying them means re-spelling the STORED key β and the ingestion pipeline matches a reply to its thread by an exact query on that key, so doing it naively strands every in-flight thread.MailboxTests.TheDerivedKeyIsReadable_NotTheIntakeSlugpins the difference so changing either convention is deliberate.
Putting a trail view on a page
A mailbox lists the subtree it sits in, so any folder of correspondence becomes a trail view by placing a mailbox inside it β no new area, no change to the page itself:
Create a node of type
Essentials/Mailboxin the folder, e.g.Campaign/Notus/Correspondence/Trails. Its scope is its own namespace (Campaign/Notus/Correspondence), which is exactly the folder holding the messages.Embed it from the folder's page with one region:
@@/Campaign/Notus/Correspondence/Trails/area/Inboxarea/Messagesgives the flat listing instead;@@/β¦/area/Sentandarea/Draftsare there too.
π¨ The embed is ABSOLUTE (@@/{path}/area/{Area}) because the page itself is usually a Markdown
node, and @@("area/Inbox") resolves against the CURRENT node's type β a Markdown node has no
mailbox area, so the relative form renders nothing.
The shorter way: the embeddable region
Creating a child mailbox per page works, but it is a node to make and remember for every page that
wants its own mail. The Trails region needs none: one mailbox ships at
Essentials/Correspondence, and the page names its own subtree as the area id.
@@/Essentials/Correspondence/area/Trails/Campaign/Notus/Correspondence
Everything after the area name is the id, and the id is the subtree listed β so the same shipped mailbox serves every page. With no id it falls back to the host mailbox's own namespace, so mounting it on a real mailbox still does the obvious thing.
It renders the grouped list alone: no heading, no hint, because the page that mounts it already has a title. Its + files a new message into the very subtree it lists.
π¨ The id widens what is LISTED, never what is READABLE. The region hands the GUI the same declared query every other view uses, and the GUI access-checks each hit β so a region aimed at a subtree its reader may not see comes back empty rather than leaking. That is precisely why the scope may be taken from the page: it is not a permission.
Prefer the region. Reach for a child mailbox only when the page wants the full app β Drafts and Sent as separate tabs rather than one list.
Two message types, one list
A mailbox lists both Essentials/Email (the drafts it writes) and the platform's Email (what
the inbound pipeline and every filing flow record) β EmailActions.AnyEmailType. Asking for only the
first is not a narrower view, it is an empty one: on 2026-09-17 all 52 filed records under
Campaign/Notus/Correspondence appeared in no mailbox at all.
Both carry a ThreadKey, so the control groups them the same way β but the two are spelled
differently, and that is documented on MailboxAreas.TrailProperty rather than papered over: intake
slugifies and keeps a gateway banner, the derived key strips it and stays readable. Within one type
the grouping is consistent, which is why filed correspondence reads correctly today; an exchange
holding both a filed message and a draft shows as two sections until the intake normalizer changes,
and that one is load-bearing (the ingestion pipeline matches replies by an exact query on the stored
key).
Where things are
| Piece | Where |
|---|---|
| Normalization, trail model, ordering, participants | Essentials/Email/Source/MailTrails.cs (shared into Mailbox) |
| The key a message carries | EmailContent.ThreadKey (derived) Β· Email.ThreadKey (stored at intake) |
| The grouped view, and the flat one beside it | MailboxAreas.Trails, BuildInbox, BuildMessages |
| The embeddable region and its scope | MailboxAreas.TrailsRegion, AreaScope, BuildTrailsRegion |
| The shipped host any page embeds | Essentials/Correspondence.json |
| The cases | Essentials/Mailbox/Test/MailboxTests.cs, and the executed round-trip in MailboxLiveTests |