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:

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:

  1. 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.
  2. 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

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:

  1. Create a node of type Essentials/Mailbox in 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.

  2. Embed it from the folder's page with one region:

    @@/Campaign/Notus/Correspondence/Trails/area/Inbox
    

    area/Messages gives the flat listing instead; @@/…/area/Sent and area/Drafts are 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
Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.