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
MeshWeaver.Teamsis the Bot Framework channel: it holds the bot's app credential, not anybody's delegated access, and does not carry the Microsoft Graph SDK.- The person's delegated Graph access — the Executive Assistant's consent (
IEaGraphAuth), which already asks forChat.Readand powers the assistant'sListChats/ReadChattools — lives inMeshWeaver.Mail.MicrosoftGraph. Nobody connects twice.
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
- 🚨 Owner only. The read runs on the owner's own token, so any other caller — another user, the
anonymous visitor, the System identity of a synced query — gets an empty answer and Graph is never
called (the same
IsOwnerrule as the live Outlook mailbox). - 🚨 Three states, never folded. Not connected →
TeamsNotConnectedExceptionnaming/auth/ea/connect; a credential read with no answer → a plain error that says it is not a reason to reconnect (#3433); a Graph failure → an error. None of them is an empty list. - Search is matched here. Graph has no
$searchon a chat listing, so the search box is matched case-insensitively, word by word, against title, last sender and preview while the most recent chats are paged — at most 5 × 50 chats are looked at, so an older chat is not found. - Newest first only. Graph supports only
lastMessagePreview/createdDateTime desc. An ascending sort, or a sort on anything but time, is refused by name rather than answered from the wrong end.
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.