Personal triage

"i would like you to pre-triage all my emails and imessage and whatsapp and show me categories such as where i should reply, where i should take action or decide, the threads i am working on" · "especially triage out what you consider spam or uninteresting commercials, what you consider important to read, where action is required" · "this adds the most value to my life" · "try to distinguish between what's important for me vs what i can ignore" · "maybe add tags? similar to github issues?" — the maintainer, 2026-10-13.

Status: building — the live Outlook source, its triage verdicts and the opt-in Outlook copy are built (below). Decided with the maintainer on 2026-10-13: the triage model is the instance's EU model catalogue; messages are read live from their provider, never replicated ("it should take from graph, not necessarily replicate"); the side menu shows the triage on top, the kinds (sources) below.

What is stored, and what is not

stored in the mesh read live from the provider
the message (body, attachments) never — unless the person switches the opt-in copy on (below): then the body AND the message's real file attachments (up to 25 MB each; the person's explicit opt-in is the one exception to "external file content never enters the mesh unless the person saves it") yes — opened and searched through the provider; a file over 25 MB, an inline image and an item or reference attachment are only ever read live
the VERDICT yes — {user}/_Triage/{provider}-{messageId} —

A verdict holds only what the inbox row needs and what the triage decided:

field meaning
provider an open string: outlook, gmail, imessage, whatsapp
messageId the provider's own IMMUTABLE id — how the verdict is joined to the live row
from, subject, receivedAt the row (receivedAt is the row's time — InboxQueryLeg.TimeField)
webLink the message in its provider's own client — offered as Open in … on the reading view, never where a click goes
importance an open string: important or ignorable
tags open strings, several per message (below)
reason one line: why this verdict — shown on the row's tooltip
model, triagedAt which model decided, and when
replyObvious, replyReason whether the answer to a reply message is obvious enough for a prepared draft, and why (prepared replies)
draftState, draftLink a mirror of the prepared-reply record for the row: ready and where to open the draft
correctedBy, correctedAt the person who last corrected importance or tags (null while they are the model's) — a person's edit is never overwritten

Importance and tags

Importance answers the one question that matters most: is this for me, or can I ignore it? Ignorable — spam, promotions, newsletters, automated notices nobody asked for — is out of sight by default, one switch away.

Tags are GitHub-style labels, OPEN strings (never an enum — a package or a person adds their own), several per message:

tag the message…
act asks you to do something
decide needs your decision or approval
reply waits for your answer
read is important to read, nothing to do
working-on belongs to a thread you are active in
waiting you replied; the other side owes the answer
fyi is informative, nothing owed
promo is commercial
spam is unwanted
topic the agent proposes topics — a client, a project, billing, travel

The flow

new message (Graph change notification / poll) ──▶ triage job (the user's delegated credential)
        ──▶ the EU model: importance + tags + reason ──▶ verdict node {user}/_Triage/{provider}-{id}
        ──▶ the personal inbox: a leg over {viewer}/_Triage, filtered by importance and tags

In the inbox

Built: live Outlook source

The person's Outlook Inbox is a source of the unified inbox (/{user}/Inbox), read live — nothing is replicated. It ships in the MeshWeaver.Mail.MicrosoftGraph module (Inbox/):

piece what it does
OutlookMailboxQueryProvider answers the reserved namespace {user}/_Mailbox/outlook (nothing is stored there) by calling Graph GET /me/mailFolders/inbox/messages — $top = the query's limit (at most 100), $orderby=receivedDateTime desc, and only id, subject, from, receivedDateTime, isRead, webLink, conversationId, bodyPreview. The LIST never reads a body; the reading view reads one message's body when it is opened (below).
search the inbox's search box becomes Graph's own $search. Graph refuses $search together with $orderby, so a search asks without it and the rows are ordered newest first here.
one message → one node {user}/_Mailbox/outlook/{id} (the id base64url-encoded), node type Mailbox/OutlookMessage, title = subject ((no subject) when empty), content receivedAt (UTC), from (display name, else address), fromAddress, isRead, webLink (the reading view's Open in Outlook), conversationId, preview.
the leg source Outlook 📧, category Mail, band NeedsYou, timed by receivedAt, anchored on the viewer's own partition. Listed only for a viewer who CONNECTED a mailbox (below).
the account every row carries account — the address of the mailbox it reached — which core's inbox shows in its Account column (owner, 2026-10-15: "we have many emails => show which mail account it is"). The leg reads the PRIMARY mailbox, so that is its address; a grant written before addresses were recorded has none, and the column stays empty rather than inventing one. The address is read beside the messages from the stored grants (IEaGraphAuth.GetMailboxes); a read that fails or takes over 5 s answers the rows without it (logged).
connected mailboxes {user}/_Mailbox/outlook-accounts/{accountId}, node type Mailbox/OutlookAccount — one node per mailbox whose grant is not positively not connected, primary first; content accountId, address, primary, never a token. A sort: on name, path or id orders the nodes BEFORE the limit; any other sort is refused by name. Answered from the stored grants, never from Graph, and only to the owner. An empty answer means nothing is connected (also on a host with no Microsoft connection configured); a grant list that cannot be read is an error, never an empty list.
Open (the reading view) a click on a row ALWAYS opens the message INSIDE the portal — core's inbox reading view, /{user}/Inbox/{item} (owner, 2026-10-16: "when clicking, it should always give me a memex view with the content, not redirect me"). OutlookInboxItemReader reads it LIVE when it is opened: Graph GET /me/messages/{id} with Prefer: outlook.body-content-type="text" (and the immutable-id preference), $select=id,subject,from,receivedDateTime,body,webLink. The view shows subject, sender, the account, the time in the viewer's zone and the body as plain text (an HTML answer is stripped); Open in Outlook (webLink) is a secondary link under it. Nothing is stored; only the owner is answered; a message Graph no longer has, or a mailbox not connected, is said.
Delete / Undo OutlookInboxItemHandler: Delete is Graph's move to Deleted Items, Undo moves it back to the Inbox. Nothing is written to the mesh. Every call asks Graph for immutable ids, which survive a move, so the row's path still names the message after Delete; the id a move returns is remembered too and preferred by Undo.

Only the owner is answered. The query provider and the handler compare the caller with the {user} segment; anyone else — another user, the system identity, the anonymous visitor — gets nothing, and Graph is not called. Every read and move runs under the person's own delegated credential (IEaGraphAuth, the Executive Assistant's consent), inside the HTTP I/O pool.

Only connected sources are listed (owner, 2026-10-15: "i see a lot of stuff that's not even configured ==> must get only configured sources"). The leg names its connection — InboxQueryLeg.ConnectedWhen = namespace:{viewer}/_Mailbox/outlook-accounts nodeType:Mailbox/OutlookAccount content.primary:true limit:1 select:path — and core's inbox lists Outlook (switch, menu entry, read) only when that finds a node. Only the PRIMARY mailbox counts, because the leg reads the primary mailbox alone: an added mailbox with a disconnected primary would list a source whose every read fails as not connected. The grant itself lives in the Auth partition, which an inbox leg may not read; the virtual outlook-accounts folder is its face inside the viewer's own partition. A connection query that cannot be read or does not answer in 5 s LISTS the source (core's rule), so a connected person is never hidden by a slow credential store.

How to connect: open {instance}/auth/ea/connect once and consent. Until then Outlook is not listed at all. Should a listed mailbox's grant later fail, the inbox names the Outlook source above its grid with "Outlook is not connected — open /auth/ea/connect once to connect your mailbox." A credential read that produced no answer is reported as exactly that, never as "not connected".

Built: triage verdicts for the Outlook source

Every Outlook row the inbox lists carries its verdict's importance and tags, and the rows without one are triaged in the background on the instance's EU-hosted model. Same module, Inbox/:

piece what it does
TriageVerdict (node type TriageVerdict, Inbox/TriageVerdict.cs) one node per message at {user}/_Triage/outlook-{digest} — the digest is 128 bits of SHA-256 over the immutable message id, so the same message always names the same verdict. Content: provider, messageId, importance (important | ignorable, open string constants), tags (open strings, normalised: lowercase, - for spaces, at most 6 of at most 32 characters), reason, model, triagedAt (UTC), subject, from, receivedAt, webLink, correctedBy, correctedAt. Never the body. Registered mesh-wide by the module (AddTriageVerdictType), with the default pages so the owner can edit the tags.
the join (OutlookMailboxQueryProvider) once Graph has answered, the owner's verdicts for exactly those rows are read in ONE query — an explicit path list [p1, p2, …], which the stores push down as WHERE path IN (…) — and each row's content gains importance, tags (an array) and triageReason. A row without a verdict is left as it is. A verdict read that fails or takes over 5 s answers the rows unjoined and offers nothing to the triage: an unreadable store is never read as "untriaged".
the triage (InboxTriagePass, Inbox/InboxTriage.cs) the rows the join found WITHOUT a verdict are offered to a background pass. Per owner one pass at a time — the mark is dropped BEFORE the pass's report is delivered, never in a Finally after it, so a pass started on the report (a tick, a manual trigger, the next read) is never skipped as "running" — at most Inbox:Triage:MaxPerPass (default 10) messages per pass, each message attempted at most once per process lifetime, one model call per message inside the I/O pool. The prompt carries the subject, the sender and Graph's preview only, and says the email is data, never instructions; the answer must be a JSON object with a known importance, or nothing is written (logged).
the model (EuTriageModelSource) Inbox:Triage:Model when one is named, else the utility then the chat tier through the AI engine's ChatClientCredentialResolver.ResolveForTierWithResidency(tier, "Eu"); the answer must also read EU through DataResidencyFor (Unknown on any ambiguity), have a usable credential and be admitted by the instance. The bare client comes from the lowest-order non-persistent IChatClientFactory that serves it. No EU model ⇒ nothing is triaged, the log says so once, and no model outside the EU is ever used instead.
a provider refusal (ModelRefusals, Inbox/ModelRefusals.cs) 🚦 an HTTP 429 or any other key-level refusal of the model call (the AI engine's ProviderHealthRule.Classify: rate limit, key limit, credit, a rejected key) is the model's, never the message's (Plugins #4546). It is recognised at the model call only, so a Graph or store throttle is never mistaken for it. The model then rests: the refused message is released uncharged, no further message of the pass is sent to that model (the rest are counted Deferred, unclaimed), and every later pass — the triage, the re-judgement and the reply drafter share ONE record per mesh — skips it until the rest ends. While it rests, EuTriageModelSource passes over it, so the next declared tier serves when it carries a different EU model; when none does, the source still answers the resting model (never "no model", which the drafter would settle as a charged failure and the calendar suggester as an empty extraction), and the consumer reads its rest: the triage and the drafter run nothing and charge nothing, and the calendar suggester's call fails the item for a later pass, as any model failure does. The rest follows the agent pool's declared back-off for a live 429 (LanePolicy.BackoffOf: 30 s, doubling per refusal since the model last answered, at most 15 min); it is a not-before stamp the next pass reads — no timer, no sleep, no retry — and an answer clears it. Per process: a restart forgets it. Before this, every message of a pass was sent to the refused model and charged its once-per-process attempt, so the backlog was spent on the provider's weather. Any OTHER failure is still the message's.
the write (MeshTriageVerdictStore) create-only, as System on the owner's behalf (the pass runs after the request has gone), scoped to the owner's _Triage. An existing verdict refuses the create and is left exactly as it is — so a verdict the owner corrected is never overwritten, because the model never overwrites anything.

A filter on the verdict (content.importance:important, content.tags:reply, a negation) is decided on the ENRICHED row: the owner's newest 1000 verdicts are read first and each Graph candidate is matched with its verdict stamped in, so the branch's limit still counts matching rows while Graph is paged, and an untriaged row matches -content.importance:ignorable. (A verdict older than those 1000 is still joined onto its row, but not seen by such a filter.) A namespace alternation over several mailboxes is authorized per owner.

Why this cannot feed on its own writes. The pass is triggered by a READ of the live mailbox, never by a query over _Triage, so a verdict it writes cannot trigger it again. Should a pass still find a verdict this process wrote itself (its claim set is bounded and starts over), it logs an Error naming the path.

Why Mail may depend on the AI engine. It already does (the Executive Assistant's tools and the connection providers), and the package declares AI in its requires; the triage adds no edge.

Configuration (Inbox:Triage): Enabled (default true), MaxPerPass (default 10), Model (an explicit selection, still used only when it is marked EU).

Not yet: the person's edit does not stamp correctedBy by itself — the generic editor writes the tags; the model already leaves every existing verdict alone, so the field is a statement for readers until the inbox's own tag action sets it. A verdict written during a render shows on the inbox's NEXT read. Thread context (earlier messages of the conversation) and corrections given to the model as examples are not built.

Still open from the design above: the tag side menu and hiding ignorable mail (core's inbox, which reads importance and tags from the row content), mark read, thread context, and the other providers.

Built: the opt-in copy

"can you also optionally offer that data is replicated?" — the maintainer, 2026-10-16. Decided the same day: when a source's copy is switched on, memex keeps full messages (subject, sender, account, time and the whole body), backfilling the last 90 days and then keeping up. Live stays the default. Amended 2026-10-24 (maintainer): the copy also stores each message's real FILE attachments, up to 25 MB each — see Attachments in the copy below.

Built for Outlook (every connected mailbox) and Gmail. 🚨 Per account, per installation, opt-in: the switch is one per connected account, on the installation the person is using — a copy exists only where it was switched on, connecting an account never starts one, and a Google or Microsoft grant held on a mother instance for a child implies no copy there. The Inbox copies tab lists every connected account with its own switch (all off); IMailCopySwitch (Offer, Enable, Disable, IsOn) is the call onboarding makes to ask "Keep a copy on this installation?". The provider-neutral half (stored shapes, copy store, generic engine, attachment rules, the switch) lives in this package's module, namespace MeshWeaver.Mail.MailCopy — see The Opt-In Mail Copy; the Google package requires Mail for it. 🚨 The copies live in a partition of their own per person, {owner}_MailCopy, never in the home partition (owner, 2026-10-25: "i don't feel good downloading all this stuff to user space. let's see that we create a dedicated partition just for the inbox for every user"). It is made as System on switch-on, and its one grant is the person's own Viewer grant: a grant on the home partition does not reach the mail. A copy made before that date sat at {owner}/_MailCopy/…; its next sync moves it — source with its cursor, copies, attachment files, then the legacy nodes and files are deleted — so nothing is downloaded again (The Opt-In Mail Copy). The default account of a provider keeps the key {provider} ({owner}_MailCopy/{provider}); every other connected account is its own copy at {owner}_MailCopy/{provider}.{accountId}. Gmail's copy follows Gmail's history id (a history id Gmail no longer knows rebuilds the copy as a new generation). Not yet: the inbox reads a copy back only for the default Outlook account (the other mailboxes and Gmail are synced and searchable as nodes, but their inbox rows are still read live), and Gmail supports one Google account per person today. Teams chats and the Mac sources (iMessage, WhatsApp) are not copied — see Not built below.

piece what it does
the switch Settings → Inbox copies (/{user}/Settings/InboxCopies, a person-app tab, MailCopySettings): a TEMPLATE that loads nothing on the hub — a reactive mesh search lists each copy that is on through its source node's own Status area (the node's fields read-only and bound: when it started, the window, whether the backfill is done, the last sync and the last error), a note that with nothing listed Outlook is read live, and Keep a copy of Outlook in memex / Stop and delete the copies. Stopping asks first and says the copies will be deleted; the mailbox is never touched. Only the space's own person can press either — the click checks it again (KeepACopy, DeleteTheCopies), and the confirmation reads the clicker anew, so a dialog the owner opened and someone else confirmed deletes nothing.
the source node {user}_MailCopy/outlook, node type MailCopySource: generation, enabledAt, windowStart (= enabledAt − 90 days), cursor (Graph's next or delta link), backfilled, lastSyncAt, lastError, leaseHolder, leaseUntil, mailboxId (the Microsoft account the copy was made from — the delegated token's own tid/oid claims, or for an opaque personal-account token the Inbox folder's id; never /me, which needs User.Read, a scope the consent does not ask for). Its absence IS "off". It is created once when the person switches the copy on, and afterwards changed only by a generation-checked commit at its owning hub (IMailCopyStore.Commit — the change is applied to the node as it is THERE, and only while it carries the generation the sync read): a sync can never re-create a source the person switched off, nor overwrite one switched on again.
the generation a fresh id per switch-on and per rebuild. Every copy carries it and its id includes it, so a late write of an earlier generation lands BESIDE the current copy, never on it, and that generation's leftovers are removed by generation (RemoveGeneration) without touching the current one.
one copy {user}_MailCopy/outlook/{digest}, node type MailMessageCopy — the digest is 128 bits of SHA-256 over (generation, immutable message id), so a re-sync overwrites (create-or-replace) and never duplicates. Content: messageId, generation, subject, from, fromAddress, receivedAt (UTC), receivedTicks (Unix ms), orderKey (receivedTicks × 1000 + three digits of a hash of the id — what the store orders and pages by: a number, since a stored instant's text form does not compare in order; unique within a millisecond, so a page boundary advances past messages received together; below 2^53, so it compares exactly as a double), isRead, webLink, conversationId, preview, body (plain text — an HTML body is stripped before it is stored), copiedAt (when this copy was written — a late Delete compares against it).
the sync (OutlookMailCopy) Graph's delta over the Inbox folder (/me/mailFolders/inbox/messages/delta), unfiltered — Graph caps a delta carrying a receivedDateTime filter at 5,000 messages and still answers a delta link, so a filtered backfill of a busy Inbox would end "done" with part of the window missing; the 90-day window is applied to what comes back. Body as text, immutable ids; up to 20 pages of 50 per step and 10 steps per sync. Within a step each message's LAST event wins (Graph may name one message on several pages — changed then removed, changed twice), so the step writes every id once and its concurrent writes cannot apply an earlier event after a later one. A removal (moved out of the Inbox, deleted) replaces the copy by a tombstone dated now (no subject, sender or body; never listed — left out before the limit — nor opened), and an inbox Delete leaves one dated when it was asked: a write older than the removal (an Undo whose Graph read lost the race) is refused against it, a newer one replaces it. The cursor is committed after every step. A failed step records lastError and keeps its cursor, so the next sync repeats it. When Graph refuses the cursor (HTTP 410, syncStateNotFound, syncStateInvalid, resyncRequired) the copy is rebuilt: a new generation, no cursor, backfilled false — the inbox reads Outlook live until the fresh backfill is done — and the old generation's copies are removed, so nothing deleted while the cursor was dead survives. One sync per copy at a time, across replicas: a sync first takes the copy's LEASE (leaseHolder/leaseUntil, 10 minutes, through the generation-checked commit); another sync that finds it held stands down without reading Graph. The lease is renewed before every step's writes and with every cursor commit, and released when the sync ends — a failed one too; a crashed holder's lease lapses. A step whose renewal is refused writes nothing. A step's writes never short-circuit: a failed write is counted, the rest still run, and the cursor is then NOT advanced (the step repeats) — so the commit, or the clean-up after a switch-off, always covers the whole step. At most one sync every 2 minutes unless the person asked; a sync's first lookup is bounded (5 s) and a whole sync at most 30 minutes, so a stalled one always frees the person for the next. 🚨 The delta steps run in a pool of their OWN (2 at a time), so backfills in flight never take the slots the live inbox, a message open or Delete/Undo need.
the account A copy is bound to the account it was made from (mailboxId, recorded by the first sync). It is served — in the list and in the reading view — only while the primary mailbox is connected to THAT account (read from the token or the Inbox folder, remembered per CONNECTION — keyed by the delegated token, so reconnecting another account reads it again; a credential read that gives no answer is an error, so the inbox reads live and a sync releases its lease). When the person connects another account the inbox reads Outlook live at once, and the next sync rebuilds the copy from the new account (a new generation; the old account's copies are removed). Every sync also sweeps leftovers of earlier generations — a rebuild whose clean-up was interrupted — not only when the copy is off.
the generation id 🚨 also owner-writable content, and spliced into store queries — so only a well-formed id (1–64 letters, digits, -) is ever used in one; anything else is refused before the query, and a sync that finds one rebuilds the copy under a fresh id. Every privileged delete re-checks each node first: directly under the copy's own source path, of the copy type, of exactly that generation.
the cursor 🚨 A stored cursor is node content in the person's own partition, so it is never trusted: a cursor (or a next link Graph returned) that is not absolute HTTPS on graph.microsoft.com, default port, without user info, is refused BEFORE any request and the copy is rebuilt from a fresh delta. The delegated token is handed only to Graph URLs — any other request goes without it. A sync that fails before its steps (the account lookup, the sweep) records lastError too, without moving the cursor.
one connection An Undo reads its message through ONE connection whose account must BE the copy's (after a reconnect, before the next sync rebuilds, it copies nothing), and its clean-up after a switch-off is a tombstone at the Undo's own time — it replaces only what the Undo wrote. A sync makes ONE credential read and binds to it: the account check AND every delta step use that token, so a reconnect to another account mid-sync can never land that account's mail under the first one's identity.
what starts a sync switching the copy on; every read of the inbox's Outlook leg by its owner — with the copy off too, where the sync only sweeps leftovers (so a late write behind a switch-off is swept even after a restart); and MailCopyKeeper, every 10 minutes, for every person this process has synced. 🚨 Never a change under _MailCopy — the sync writes only there, so it is never fed its own writes.
the inbox The Outlook leg answers from the copy once the backfill is done — before that, and whenever the copy is off or the decision (the source read AND the account check, one 5 s budget for both) cannot be made in time, it reads Graph live. 🚨 The copy holds only its window, so it answers ALONE only when it fills the page newest first (those are then exactly the newest rows); an oldest-first list is read live; and a page the copy cannot fill — a search, an old unread message — is the copy's rows unioned with the live ones (each message once, the copy's version first), so nothing outside the window disappears. The copy is turned back into the same Graph messages the live read returns, so the rows, filters, triage join and account stamp are the live ones, and Outlook is listed once either way. The copy is paged in receive order (200 at a time, at most 100 pages) until enough copies MATCH the search and the row filters — never cut at a fixed count first, which would answer a match just past the cut as absent. A search needs every word in the subject, sender, preview or body. 🚨 The read from the copy is bounded (10 s) and falls back to Graph live on a failure or a stall, so a copy can never fail or hang an inbox whose Outlook is reachable; and it is re-checked after the pages: if a sync rebuilt the copy or the person switched it off while it was read, the answer is refused for the live one rather than given empty or partial.
the reading view shows the stored body when the copy holds the message; otherwise — or when the copy cannot be read within 5 s — reads Graph live, as before.
Delete / Undo still move the message in Outlook; Delete then removes its copy — unless the copy reflects something AFTER the Delete was asked for (copiedAt: a sync since, or an Undo ASKED later — an Undo's copy carries the time it was asked, so the order of the person's moves decides, never how long a write took; and every move is stamped BEFORE its Graph request is sent, so a late response never makes an earlier move look newer) — and Undo re-reads it and writes the copy back only when it was received inside the window, and only over an OLDER copy (WriteIfNewer, decided at the owning hub — a sync's newer state is never replaced by an Undo's late Graph read), as the sync would, then checks again that the copy is still on in the same generation — if the person switched it off (or on anew) while Graph was being read, or the re-check cannot be read, the copy Undo wrote is removed again. The follow-ups of one message apply in the order they were asked for (in a process); a stalled one holds the next back at most 10 s, after which the asked-time stamps alone keep the order. The follow-up runs detached and the outcome waits for it at most 5 s, so a slow copy never holds back a move Outlook already made. The next delta reconciles either way.
switching off deletes {user}_MailCopy/outlook (a copy still at the legacy place is moved first) and every copy under it, of every generation, in one call. A sync running at that moment finds its commit refused, removes the copies it wrote meanwhile by their generation, and never switches the copy back on — and if the person switched it on again in between, the new copy is left alone. A copy a late write left behind anyway is swept by the next sync of that person (which, for a copy that is off, only looks for leftovers — Sweep). 🚨 The sweep LISTS the copies first and reads the source AFTER, removing only copies whose generation is not the source's as it then stands — so a copy switched on (and backfilled) by another replica meanwhile is never touched.

No history. Both node types are registered with keepsHistory: false, so no version snapshot of a copied body is kept — on Postgres the trigger-written snapshots are purged after each write, the platform's own behaviour for such a type (core NoHistoryTypePgTests). "Stop and delete the copies" therefore leaves nothing of the mail behind, history included. Every write of a sync step — changes and tombstones — goes through WriteIfNewer, dated when Graph was ACTUALLY asked — taken inside the request's own pool slot, never when the step was queued — so a Delete or Undo asked after it is never overwritten, and an outside change Graph reports after the person's move is never refused as stale. 🚨 And because ordering by clocks is only ever a guess (a retried request, a slow response), an event that LOSES to a local action is not simply dropped: before the cursor moves past it the message is re-read from the Inbox as it is NOW (/me/mailFolders/inbox/messages/{id}) and the copy settles on that — copied when it is there, tombstoned when it is not — as a compare-and-set on the stored revision (the node's own version, which moves on EVERY accepted write — copiedAt orders actions only) read BEFORE Graph was asked: a Delete or Undo that completed while the answer was in flight makes it a conflict, and the reconcile asks again (up to 3 times, then the step repeats).

Access. The copies live in the person's own copy partition, {user}_MailCopy, and are read as that person through its one grant, their Viewer grant; the background writes run as System on their behalf, scoped to that partition. Nothing grants anyone else a read — a global administrator included, and a grant on the person's home partition stops at the home partition. Proven by MeshWeaver.Mail.MicrosoftGraph.Test — OutlookMailCopyTests (scripted Graph), MailCopyMeshTest (a real mesh) and MailCopyAccessMeshTest (a mesh with row-level security and no blanket grant: another person reads nothing, the owner's identical query finds the copy).

Not built. Teams chats, Gmail, iMessage and WhatsApp copies. Gmail lives in its own module (MeshWeaver.Mail.Gmail), which shares no assembly with this one, so its copy needs either a shared home for the two node types or its own; the Mac sources run in the LocalMesh app on the Mac, where the chat databases already are. After a restart, a copy catches up the next time its owner opens the inbox (the keeper only revisits people this process has seen); nothing is missed, since Graph's delta is cumulative. The inbox's own settings menu does not yet link to the tab (core's inbox has no extension point for it); the tab is in the person's settings app.

Attachments in the copy

The copy is the person's explicit opt-in, so for it — and only it — the rule that external file content never enters the mesh unless the person saves it is reversed (GovernedMail rule 7 names the exception).

Built: Emails is a search scope — off in the top search unless asked

"emails in top search are sensitive. should have an option to have them disabled by default and only when explicitly checking emails as search scope we will do it" — the maintainer, 2026-10-24.

Where mail could appear in a search before. The portal's top bar and /search page run partitions:all free-text queries over the mesh. The live mailbox providers ({user}/_Mailbox/outlook, …/gmail, {user}/_Chats/teams) answer only a query whose namespace is exactly theirs, so a mesh-wide search never reaches them. The opt-in copies are ordinary nodes at {user}_MailCopy/{provider}/{digest} — a top-level partition that a partitions:all search reaches directly — (and whatever is stored below a copy, attachments included); the bar's suggestion dropdown (context:search hides underscore paths) never listed them, but the search page did — it states no context — and so did the page's In documents section for any chunk under the searched namespace.

Now: mail is a named scope, off unless asked for.

surface default how mail is asked for
the top bar's dropdown excluded the Emails checkbox at the top of the dropdown, or source:mail in the typed term
the search page (/search) excluded the Emails checkbox above the results, or source:mail in the box
the page's In documents chunks dropped when the scope is off the same checkbox / token
a person's own default includeEmailsInTopSearch on {user}/_Mail/Preferences (the Mail tab of their settings), false switching it on makes the checkbox start ticked; -source:mail still refuses it for one search

Not covered yet. The MCP search tool and the AI agents' default searches build their own queries and do not go through this seam: a partitions:all search from an agent can still find the owner's copies (only the owner can read them). The Emails scope also searches the copies only; to search a mailbox that has no copy, use the inbox's own search, which reads the provider live. Gmail-only deployments (no Outlook module) have no Emails scope entry — Gmail keeps no copy and its live namespace is never reached by a mesh-wide search, so there is nothing to exclude.

Built: the person's mail preferences — the three questions

The setup wizard's mail panel (MailOnboardingPanel, area OnboardingMail on the person's own user node; also the Mail tab of their settings app, /{user}/Settings/Mail) asks the owner's three questions (2026-10-17) and stores the answers on {user}/_Mail/Preferences (MailPreferences, a form bound straight to the node):

question field default what it drives
should mails be pre-categorized? sorting on the inbox offers a person's untriaged rows to the pass only while this is on (OutlookMailboxQueryProvider, read live on every offer; an unreadable preference reads as the default)
should it be synced, and for how long? the Inbox copies switch (unchanged, one switch, with its confirmation) + copyWindow (last month / three months / year) off · three months Keep a copy starts the backfill at the chosen window (OutlookMailCopy.Enable(owner, windowDays))
should replies be prepared even before reading? - the reply can be edited before sending replies (Prepare replies when the answer is obvious / Never) when obvious the reply pre-drafter
should emails be searched by the top search? (2026-10-24) includeEmailsInTopSearch off the default of the Emails search scope (below); read live by the search bar and the search page

Built: prepared replies - an obvious answer is drafted for you

"if i need to answer, have an agent prepare the answer for me" - the owner, 2026-10-25; narrowed the same day: "only when ingest thinks the answer is obvious"; and "as email ... or whatever the comms channel is".

When the triage puts a message in the Reply bucket AND judges that the answer is obvious, the instance prepares the reply for the person to review. Nothing is ever sent: the person presses Send, exactly as Email:AgentSend = DraftOnly promises.

What "obvious" means, and who decides

The triage model answers one more question next to the tags, in the same call: replyObvious (true or false) and replyReason (one line). Obvious is a confirmation, a scheduling yes or no, a plain factual answer from what the message itself says, a simple acknowledgement or thanks. Anything that needs the person's judgement, negotiation, a commitment, money, a legal view or a sensitive touch is false - "when in doubt, false" is in the prompt - and gets no draft: it stays in the Reply bucket for the person to write. Only a JSON true on a message tagged reply counts. The flag is stored on the verdict (ReplyObvious, ReplyReason) beside the tags, so a person can see why. A second check sits at the draft: the drafting model reads the WHOLE message (the triage saw only the preview) and may hand it back with DECLINE: <why> - then no draft is written and the record says so.

The flow

verdict {reply, replyObvious} ──▶ ReplyOfferingVerdictStore ──▶ ReplyDrafter (per owner, bounded)
   (created by the triage, or read for the inbox)                 │ person's preference  (Replies = WhenObvious)
                                                                  │ EU model available?
                                                                  ▼
                       claim {user}/_Replies/{provider}-{digest}  (create-only record, lease)
                                                                  ▼
        IReplyPreparer(provider).Read ─▶ EU model composes ─▶ FindExisting ─▶ Create | Amend
                                                                  ▼
                 record: ready + draft link ─▶ mirrored on the verdict ─▶ the inbox row and reading view

What the person sees

The one-time backlog re-judgement

Verdicts written before the reply judgement existed (Plugins #4199) carry no replyObvious, so none of them could ever qualify for a prepared reply. The inbox read re-judges them once, inside the same live pass (no schedule):

The seam for other channels

The drafter knows no channel. Routing is by the verdict's Provider string:

to plug in what the channel brings
a channel that is triaged (Teams chats, Gmail, WhatsApp, iMessage) a triage path that writes TriageVerdicts with its own Provider through ITriageVerdictStore (so they pass the offering decorator), and the same replyObvious judgement
the preparer an IReplyPreparer (MeshWeaver.AI.Replies) registered in DI with Provider equal to that string: Read the item and its conversation live, FindExisting, Create, Amend, plus the ConnectHint sentence

The contract has no send - by design - and states the rules every preparer keeps: one prepared reply per item and per conversation, amend instead of re-drafting, never touch what the person sent or edited, the person's own credential only. A channel with no native draft (Teams chats) keeps the prepared text on the chat item instead of in a Drafts folder: Create stores it on the item and returns a ReplyDraftRef whose Link opens the chat in the portal, Amend rewrites it under the same LeftAlone rules (compare the item's own edit time), and nothing is ever posted. Outlook's preparer (OutlookReplyPreparer) is the worked example.

Triage while the inbox is closed

InboxTriageKeeper is the instance's OWN hosted job (maintainer, 2026-10-27: the owner runs the inbox on a laptop's local mesh; control is not involved for a personal instance, and the cloud instance runs the identical code). Every 15 minutes (Inbox:Triage:Background:Interval, never below a minute; Enabled, MaxOwnersPerTick, MessagesPerOwner) it lists the instance's people as System (nodeType:User), and per owner - one after the other, a cursor rotating through a list longer than the bound - it skips an owner who switched sorting off or has no stored mailbox grant (one preference read and one grant read, no Graph call, no model), otherwise reads the newest messages through OutlookMailbox, reads their verdicts through the offering store (which hands owed replies to the drafter, as an inbox read does) and gives InboxTriagePass the ones without a verdict, plus the one-time backlog re-judgement. It reuses the pass's bounds (MaxPerPass), its create-only verdicts and its per-process attempt memory, so a second replica or a restart writes nothing twice; ticks never overlap; one owner's failure is logged and the others still run. The same visit deletes, once per owner and process, the obsolete conversation lock PreparedReply nodes in _Replies (PurgeLegacyLocks: only thread: item ids with that subject and no draft - a real reply is never matched; nothing reads them any more).

Model calls: triage, re-judgement and reply composition resolve the model with the same tier logic (Inbox:Triage:Tiers, default utility then chat, EU residency; a named Model still has to be EU), so the laptop's local model is used when it is the EU-marked utility tier. Classification calls ask for ReasoningEffort.None and 600 output tokens (measured on the local utility model: 0.75 s per message with thinking off against 3.7 - 17 s on, and thinking consumed the output cap and truncated the JSON at 400 tokens); reply composition asks for Low. A provider that takes no effort parameter ignores it.

Teams suggestions

A Teams chat gets triaged and a suggested reply like a mail, with two differences forced by the connection: it holds Chat.Read only (posting needs Chat.ReadWrite, which is deliberately not asked for), so nothing is ever posted to Teams; and a chat has no id per message, so the item is a turn - TeamsChatNodes.TurnId(chatId, time of the last message). A newer message is a new turn with a new verdict and a new suggestion. The keeper reads the owner's chats beside the mail (TeamsChatNodes.ToTriageInput: chats whose last message the owner wrote are skipped), the verdicts are keyed provider = teams, and the drafter routes to TeamsReplyPreparer, which reads the chat's newest messages and keeps the text on the record (IReplyPreparer.KeepsTextOnRecord, PreparedReply.SuggestionText): it never looks for an existing reply and always writes a suggestion. The chat row carries the suggestion-ready tag (the query provider joins the verdicts by turn) and the reading view (TeamsChatInboxItemReader) shows the suggestion above the conversation, with the line that nothing has been posted. Known limit: a chat that has its own room is listed by the rooms leg, which does not join verdicts.

Calendar suggestions

"For anything which should go to the calendar, suggest to put" (owner, 2026-10-27). The triage prompt has a calendar tag (an appointment, deadline or event with a date). A verdict carrying it - from any source - is offered, through the same seam as the reply drafter (ReplyOfferingVerdictStore), to the CalendarSuggester:

Not built yet

Order of work

  1. Outlook: the triage job, the verdict node type, the inbox leg and the tag side menu.
  2. Actions on the selection (Archive, Delete, Mark read, Tag), called through Graph.
  3. Live search through the provider.
  4. Gmail, then iMessage (the Mac bridge), then the WhatsApp business channel.

What WhatsApp cannot do: WhatsApp offers no API for a person's own chats — only the business number the assistant answers on. Those conversations are triaged; a personal chat is not reachable.