Teams chats in the unified inbox

The person's own Microsoft Teams chats — one-to-one, group and meeting chats with people — are a source of their unified inbox (/{user}/Inbox), read live from Microsoft Graph. Nothing is replicated into the mesh. It is the chat twin of the live Outlook source and ships in the same compiled module, MeshWeaver.Mail.MicrosoftGraph (src/MeshWeaver.Mail.MicrosoftGraph/TeamsChats).

Owner's request (2026-10-15): "can we also then connect gmail and whatsapp … and imessage … and teams" → "please do". Before this, the inbox's Teams source listed only conversations people had with the assistant's Teams BOT (thread nodes stamped conversationOrigin.client: teams).

Why this module, not MeshWeaver.Teams

The pieces

piece what it does
TeamsChatsQueryProvider answers the reserved namespace {user}/_Chats/teams (node type Chats/TeamsChat, nothing stored) and {user}/_Chats/teams-accounts (node type Chats/TeamsAccount). Each alternative of a namespace:a\|b union and each branch of a multi-query is authorized on its own.
TeamsChatReader one Graph listing per page: GET /me/chats?$top=N&$expand=lastMessagePreview&$orderby=lastMessagePreview/createdDateTime desc, N ≤ 50 (Graph's cap), following @odata.nextLink until the leg's limit (≤ 100) of MATCHING chats is found, the list ends, or 5 pages were read. Runs inside the IIoPool leaf — no async above it.
TeamsChatNodes the chat → row mapping (below). Pure.
TeamsInboxContribution the leg and the DI registration (AddTeamsInbox, called from the module).
TeamsChatInboxItemHandler Delete and Undo — both refuse with the reason (below).

One row per chat

content field from Graph
receivedAt lastMessagePreview.createdDateTime, UTC, ISO 8601 with Z — the leg's TimeField
from the last message's sender (from.user, else from.application for a bot, else from.device)
isRead viewpoint.lastMessageReadDateTime ≥ lastMessagePreview.createdDateTime — Graph's own unread signal ("List chats", example 4). The owner's OWN last message is read. A missing read time is UNREAD (shown, never hidden).
webLink the chat's webUrl — opens it in Teams
preview the last message's text only: markup stripped, whitespace collapsed, at most 200 characters; none for a deleted or system message. No message body is ever fetched.
account the address of the PRIMARY Microsoft 365 account the chats were read with (IEaGraphAuth.GetMailboxes); omitted when the grant recorded none or the label could not be read

The row's title is the chat's topic; a chat without one (every one-to-one chat) is named after its last sender when that is someone else, else Teams chat / Teams meeting chat. Members are not expanded: Graph documents members and lastMessagePreview as expandable each on its own, not together, and a combined expand Graph might refuse would take the whole source down.

A chat with no message, one the owner hid (viewpoint.isHidden), or one hidden for all members is not listed. The owner is recognised by the oid claim of their own delegated token — no /me call, which would need User.Read, a scope the consent does not ask for.

Rules

The leg — one Teams source with the bot conversations

Source = "Teams", symbol 🟣, band Needs you, TimeField = receivedAt, anchored on namespace:{viewer}/_Chats/teams nodeType:Chats/TeamsChat … sort:LastModified-desc with the timed row projection. Read and unread chats come in one leg; each row says which.

It carries the same source id and symbol as the bot-conversation legs in the AI engine (ThreadInboxContribution), so the inbox shows one Teams switch covering both the chats with people and the ones with the assistant. It files under the one Conversations category (CategoryLabelKey = inbox.category.Conversations) that every conversation source shares — the WhatsApp, Teams and iMessage bot legs, portal chats and the live iMessage source — so Teams is never split into a folder of its own beside it (pinned by TheLeg_IsTheSameTeamsSource_AsTheBotConversations).

Connected when (InboxQueryLeg.ConnectedWhen, core MeshWeaver#6794): namespace:{viewer}/_Chats/teams-accounts nodeType:Chats/TeamsAccount limit:1 select:path — answered from the stored grant, never from Graph and without minting a token. It finds the primary account when it is not positively not connected; an added mailbox does not count, because the chats are read with the primary grant. A grant list that cannot be read is an error, never "not connected". The inbox lists the Teams source when any of its legs' probes finds a node (or names none).

Delete and Undo refuse — and why

Teams has no archive for a chat. The only write that comes close — marking it read, POST /chats/{id}/markChatReadForUser (Undo: markChatUnreadForUser) — needs the delegated Chat.ReadWrite permission. The Microsoft connection consents to Chat.Read (and ChatMessage.Send) only, and widening that scope string invalidates every stored grant until each person reconnects (see the scope notes on core's EaGraphAuth.Scopes), so it is not widened here. Marking read would not take the chat out of the leg either — it lists recent chats, read and unread — which is what the inbox's Delete promises. Both actions therefore return the refusal "A Teams chat cannot be put away from the inbox …" verbatim; the row's link opens the chat in Teams.

Tests

src/MeshWeaver.Mail.MicrosoftGraph.Test/TeamsInboxTests.cs — Graph scripted at the HTTP boundary: the mapping and the unread rule, paths and namespace shapes, the token's oid, the wire request ($top, $expand, $orderby, the owner's bearer), owner-only for chats and for the account probe, not-connected / undetermined / failed-read, the account label degrading to none, local search and paging (a non-matching newest chat does not hide a matching older one; the page cap), refused orders, union alternatives, the probe answered without a token or a Graph call, the leg resolving for viewer alice, the shared Teams source, DI, and the refusing handler.

Not established: no test calls the real Microsoft Graph. The listing shape, the viewpoint population in a delegated context and the oid claim follow Microsoft's documentation, not a live tenant read.