From f0038e1e8859c03db586e50786ef577a42e985ad Mon Sep 17 00:00:00 2001 From: Phil Wilson Date: Thu, 3 Sep 2026 13:04:32 +0100 Subject: [PATCH 1/3] Share chat attachments with the chat's members after upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `message send --chat … --attach FILE` uploads into the sender's OneDrive ("Microsoft Teams Chat Files") and links the driveItem from the message, but nobody else could open it: the Teams client grants every chat member read permission when it attaches a file, and the CLI did not. Recipients saw "you don't have permission" (observed live, 2026-09-03). After each chat upload the CLI now lists the chat's members once and grants them read access through the driveItem's `invite` action, with requireSignIn=true and no notification email. Members are addressed by Entra object ID where the membership carries one (covers accounts without a mail attribute), else by email; the sender and members with neither are skipped, and duplicates collapse. The grant is best-effort: if it fails the upload and message still go through and a warning on stderr says to share the file from OneDrive by hand. Channel attachments are unchanged — they live in the team's SharePoint library, which channel members already read. `AttachDestination::Chat` now carries the chat id so the media layer can look the members up. New: `endpoints::me_drive_item_invite`, `DriveInviteRequest` / `DriveRecipient` / `DrivePermission` models, `files::grant_read_access`. Unit tests cover recipient selection and the invite request's JSON shape; docs and CHANGELOG updated. Verified live in a four-member meeting chat: the debug log reports the file shared with 3 members and Graph lists a read permission for each. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + docs/attachments-spec.md | 11 +++ docs/command-reference.md | 2 +- src/api/endpoints.rs | 5 ++ src/api/files.rs | 26 ++++++- src/cli/message.rs | 2 +- src/cli/message_media.rs | 147 ++++++++++++++++++++++++++++++++++++-- src/models/file.rs | 57 +++++++++++++++ 8 files changed, 244 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c16565..cabc6e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Windows builds reserve an 8 MiB main-thread stack, matching Linux and macOS. Windows gives the main thread 1 MiB by default, and building clap's command tree for this many subcommands needs almost all of it in an unoptimized build, so any addition to the `message` command made every debug and test invocation of `teams` on Windows — `--help` included — fail with `thread 'main' has overflowed its stack`, and `cargo test` failed on `windows-latest` while passing on Linux and macOS. A build script now passes `/STACK:8388608` to the MSVC linker (`--stack` on the GNU toolchain). The reservation is address space rather than committed memory, so an idle process costs nothing extra. - Plain lists retain optional fields that first appear after the first row, including message subjects. Human message lists include a Subject column; JSON still omits absent subjects. - `message list` and `message get` no longer drop the `subject` of a message. The `ChatMessage` model had no `subject` field, so a channel root message's subject — returned by Graph on both reads — silently vanished from every output: a message posted with a subject read back without one. Messages without a subject are unchanged and gain no `"subject": null` noise. +- `teams message send --chat … --attach FILE` now shares each uploaded file with the chat's members. The upload lands in the sender's OneDrive (`Microsoft Teams Chat Files`), where nobody else has access; the Teams client grants every member read permission when it attaches a file, but the CLI did not, so recipients got "you don't have permission" when they opened the attachment. After each upload the CLI now lists the chat's members and grants them read access via the drive item's `invite` action (no notification email), addressing them by Entra object ID where the membership carries one, else by email, and skipping the sender. If that step fails — for example when the token cannot list chat members — the upload and the message still go through and a warning on stderr says the file must be shared from OneDrive by hand. Channel attachments are unaffected: they live in the team's SharePoint library, which channel members can already read. ## v0.6.0 - 2026-08-30 diff --git a/docs/attachments-spec.md b/docs/attachments-spec.md index 81365be..9be841b 100644 --- a/docs/attachments-spec.md +++ b/docs/attachments-spec.md @@ -411,6 +411,17 @@ delegated auth for all message mutation, so nothing changes. `"reference"`, `contentUrl` = the driveItem's `webUrl`, `name` = its `name` — plus an `` tag in the body HTML, which is what makes the attachment card render in clients. +3. **Chats only: share the file with the members.** The upload sits in the sender's own + OneDrive, where nobody else has access, and the message merely links to it. The Teams + client grants every chat member read permission when it attaches a file; the CLI does + the same via `POST /me/drive/items/{id}/invite` with `roles: ["read"]`, + `requireSignIn: true` and `sendInvitation: false`, addressing each member of + `GET /chats/{id}/members` (minus the sender) by Entra object ID, or by email when the + membership carries no ID. Without this step recipients get "you don't have + permission" when they open the attachment (observed live, 2026-09-03). The grant is + best-effort: if it fails the message is still sent and a warning on stderr says to + share the file from OneDrive by hand. Channel uploads need none of this — they live in + the team's SharePoint library, which channel members already read. Simple upload caps at 4 MB (the existing `MAX_UPLOAD_SIZE`); larger files need the upload-session API, which is out of scope here — the CLI errors clearly instead. diff --git a/docs/command-reference.md b/docs/command-reference.md index 69461f6..0371a05 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -134,7 +134,7 @@ Normal message mutation requires delegated auth. App-only/client-credentials tok `message update` edits your own message in place (Graph lets a delegated caller change any property except `policyViolation`); channel edits need the `ChannelMessage.ReadWrite` delegated scope, chat edits need `Chat.ReadWrite`. Graph returns no content on success, so the command reads the message back and prints it; if that read fails the edit has still been applied and the output is `{"id": ..., "updated": true, "readBackError": ...}`. -`--image` sends a picture the way pasting a screenshot does — the bytes travel inside the message itself (a Graph "hosted content"), so it needs no scopes beyond sending messages. `--attach` uploads the file to real storage first (your OneDrive's `Microsoft Teams Chat Files` for chats, the team's SharePoint library for channels) and links it from the message; that upload needs `Files.ReadWrite` (chats) or `Files.ReadWrite.All` (channels). Both flags repeat for multiple files, and `--body` becomes optional when either is present. Inline images are capped at 3MB each; attachments use Graph's 250MB simple-upload limit. +`--image` sends a picture the way pasting a screenshot does — the bytes travel inside the message itself (a Graph "hosted content"), so it needs no scopes beyond sending messages. `--attach` uploads the file to real storage first (your OneDrive's `Microsoft Teams Chat Files` for chats, the team's SharePoint library for channels) and links it from the message; that upload needs `Files.ReadWrite` (chats) or `Files.ReadWrite.All` (channels). For chats, the CLI then grants every other chat member read access to the uploaded file (the drive item's `invite` action, no email), because a file in your own OneDrive is otherwise unreadable to them — the Teams client does the same when you attach a file. If that grant fails the message is still sent and a warning on stderr tells you to share the file from OneDrive by hand. Both flags repeat for multiple files, and `--body` becomes optional when either is present. Inline images are capped at 3MB each; attachments use Graph's 250MB simple-upload limit. `--mention USER` tags a person as a real Teams @mention (the kind that pings them) in chat sends, channel sends, and channel replies. It is repeatable, and `USER` may be an Entra object ID or a UPN — the CLI resolves the display name through Microsoft Graph. A mention needs an HTML body plus a synchronized `mentions` array; the CLI builds both for you: a plain-text body is safely converted to HTML (escaped, line breaks preserved as `
`) and the `` elements are prepended to your body in flag order. A mention by itself counts as a body, so `--mention USER` without `--body` works. Raw `` markup typed directly into an HTML body is rejected with exit code 2 before anything is sent, because Graph does not turn it into a real mention. diff --git a/src/api/endpoints.rs b/src/api/endpoints.rs index 49e7b95..560022d 100644 --- a/src/api/endpoints.rs +++ b/src/api/endpoints.rs @@ -331,6 +331,11 @@ pub fn drive_item_create_link(drive_id: &str, item_id: &str) -> String { format!("{GRAPH_V1}/drives/{drive_id}/items/{item_id}/createLink") } +/// Grant people access to an item in the signed-in user's OneDrive. +pub fn me_drive_item_invite(item_id: &str) -> String { + format!("{GRAPH_V1}/me/drive/items/{item_id}/invite") +} + // --- Hosted contents (inline images, code snippets) --- pub fn channel_message_hosted_contents( diff --git a/src/api/files.rs b/src/api/files.rs index 40cfb41..dbecb5d 100644 --- a/src/api/files.rs +++ b/src/api/files.rs @@ -1,5 +1,9 @@ use crate::error::{Result, TeamsError}; -use crate::models::file::{DriveItem, FilesFolder, ShareLinkRequest, ShareLinkResponse}; +use crate::models::common::PageResponse; +use crate::models::file::{ + DriveInviteRequest, DriveItem, DrivePermission, DriveRecipient, FilesFolder, ShareLinkRequest, + ShareLinkResponse, +}; use super::client::{GraphClient, PaginationOpts}; use super::endpoints; @@ -313,6 +317,26 @@ pub async fn delete_file( .await } +/// Grant `recipients` read access to an item in the signed-in user's OneDrive, +/// without sending them an email. Returns the permissions that now exist for +/// them. Needs the same `Files.ReadWrite` scope as the upload itself. +pub async fn grant_read_access( + client: &GraphClient, + item_id: &str, + recipients: Vec, +) -> Result> { + let req = DriveInviteRequest { + recipients, + roles: vec!["read".to_string()], + require_sign_in: true, + send_invitation: false, + }; + let resp: PageResponse = client + .post(&endpoints::me_drive_item_invite(item_id), &req) + .await?; + Ok(resp.value) +} + pub async fn create_share_link( client: &GraphClient, team_id: &str, diff --git a/src/cli/message.rs b/src/cli/message.rs index 7f4a182..23df2b0 100644 --- a/src/cli/message.rs +++ b/src/cli/message.rs @@ -335,7 +335,7 @@ pub async fn run( &mut req, &image, &attach, - super::message_media::AttachDestination::Chat, + super::message_media::AttachDestination::Chat { chat_id: &chat_id }, ) .await?; api::messages::send_chat_message(&client, &chat_id, &req).await? diff --git a/src/cli/message_media.rs b/src/cli/message_media.rs index e09ec76..2bb7574 100644 --- a/src/cli/message_media.rs +++ b/src/cli/message_media.rs @@ -1,8 +1,10 @@ use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine as _; -use crate::api::{self, GraphClient}; +use crate::api::{self, GraphClient, PaginationOpts}; use crate::error::{Result, TeamsError}; +use crate::models::file::DriveRecipient; +use crate::models::member::ConversationMember; use crate::models::message::{ChatMessageAttachment, HostedContentUpload, SendMessageRequest}; /// Where `--attach` files get uploaded before the message references them. @@ -10,7 +12,9 @@ use crate::models::message::{ChatMessageAttachment, HostedContentUpload, SendMes /// library — which is why the two need different Files scopes (see /// docs/attachments-spec.md). pub enum AttachDestination<'a> { - Chat, + Chat { + chat_id: &'a str, + }, Channel { team_id: &'a str, channel_id: &'a str, @@ -47,11 +51,27 @@ pub async fn apply_media( let (images_html, hosted) = inline_images(images)?; body.push_str(&images_html); + // Chat uploads land in the sender's own OneDrive, where nobody else can + // read them until they are shared — look the chat's members up once so + // each uploaded file can be shared with them below. + let chat_recipients = match dest { + AttachDestination::Chat { chat_id } if !attaches.is_empty() => { + Some(chat_recipients(client, chat_id).await?) + } + _ => None, + }; + for path in attaches { let (bytes, content_type, filename) = read_attachment(path)?; let item = match dest { - AttachDestination::Chat => { - api::files::upload_chat_attachment(client, &filename, bytes, &content_type).await? + AttachDestination::Chat { .. } => { + let item = + api::files::upload_chat_attachment(client, &filename, bytes, &content_type) + .await?; + if let Some(recipients) = &chat_recipients { + share_with_chat(client, &item, &filename, recipients).await; + } + item } AttachDestination::Channel { team_id, @@ -85,6 +105,88 @@ pub async fn apply_media( Ok(()) } +/// The people a chat attachment must be shared with: every member of the chat +/// other than the sender. The Teams client grants these permissions itself +/// when a file is attached; a bare drive upload does not, so without this step +/// recipients get "you don't have permission" when they open the file. +async fn chat_recipients(client: &GraphClient, chat_id: &str) -> Result> { + let me = api::users::get_me(client).await?; + let members = api::chats::list_members( + client, + chat_id, + &PaginationOpts { + page_size: 50, + all_pages: true, + }, + ) + .await?; + Ok(invite_recipients(&members, me.id.as_deref())) +} + +/// Members addressed by Entra object ID when the membership carries one (it +/// also covers accounts with no mail attribute), else by email; the sender and +/// members with neither are skipped, and duplicates collapse. +fn invite_recipients( + members: &[ConversationMember], + sender_id: Option<&str>, +) -> Vec { + let mut seen = std::collections::HashSet::new(); + members + .iter() + .filter(|m| m.user_id.as_deref() != sender_id || sender_id.is_none()) + .filter_map(|m| match (&m.user_id, &m.email) { + (Some(id), _) => Some(DriveRecipient { + object_id: Some(id.clone()), + email: None, + }), + (None, Some(email)) => Some(DriveRecipient { + object_id: None, + email: Some(email.clone()), + }), + (None, None) => None, + }) + .filter(|r| seen.insert(r.clone())) + .collect() +} + +/// Share an uploaded chat file with the chat's members. A failure here is +/// reported, not fatal: the upload succeeded and the message can still be +/// sent, but the recipients will not be able to open the file until it is +/// shared from OneDrive by hand. +async fn share_with_chat( + client: &GraphClient, + item: &crate::models::file::DriveItem, + filename: &str, + recipients: &[DriveRecipient], +) { + if recipients.is_empty() { + return; + } + let Some(item_id) = item.id.as_deref() else { + tracing::warn!( + "Uploaded '{filename}' but the driveItem has no id, so it could not be shared \ + with the chat's members; share it from OneDrive by hand." + ); + return; + }; + match api::files::grant_read_access(client, item_id, recipients.to_vec()).await { + Ok(perms) => tracing::debug!( + "Shared '{filename}' with {} chat member(s); roles granted: {}", + recipients.len(), + perms + .iter() + .flat_map(|p| p.roles.iter()) + .map(String::as_str) + .collect::>() + .join(",") + ), + Err(e) => tracing::warn!( + "Uploaded '{filename}' but could not share it with the chat's members ({e}); \ + they will get \"you don't have permission\" until it is shared from OneDrive by hand." + ), + } +} + /// Build the body-HTML fragment and hosted-content uploads for `--image` /// files. Temporary IDs are 1-based to match Graph's documented examples. fn inline_images(images: &[String]) -> Result<(String, Vec)> { @@ -241,6 +343,43 @@ mod tests { } } + fn member(user_id: Option<&str>, email: Option<&str>) -> ConversationMember { + ConversationMember { + id: None, + display_name: None, + roles: None, + user_id: user_id.map(str::to_string), + email: email.map(str::to_string), + } + } + + #[test] + fn invite_recipients_skip_sender_prefer_object_id_and_dedupe() { + let members = [ + member(Some("me"), Some("me@example.com")), + member(Some("u1"), Some("u1@example.com")), + member(Some("u1"), None), + member(None, Some("mail-only@example.com")), + member(None, None), + ]; + let recipients = invite_recipients(&members, Some("me")); + assert_eq!( + recipients, + vec![ + DriveRecipient { + object_id: Some("u1".into()), + email: None + }, + DriveRecipient { + object_id: None, + email: Some("mail-only@example.com".into()) + }, + ] + ); + // Unknown sender: nobody is skipped on that basis. + assert_eq!(invite_recipients(&members[..2], None).len(), 2); + } + #[test] fn etag_guid_extracts_bare_guid() { assert_eq!( diff --git a/src/models/file.rs b/src/models/file.rs index e568467..130a848 100644 --- a/src/models/file.rs +++ b/src/models/file.rs @@ -110,10 +110,67 @@ pub struct SharingLink { pub scope: Option, } +/// Request to grant people access to a drive item (`POST …/invite`). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DriveInviteRequest { + pub recipients: Vec, + pub roles: Vec, + pub require_sign_in: bool, + pub send_invitation: bool, +} + +/// One recipient of a drive invite: an Entra object ID or an email address. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DriveRecipient { + #[serde(skip_serializing_if = "Option::is_none")] + pub object_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, +} + +/// A permission on a drive item, as returned by an invite. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DrivePermission { + #[serde(default)] + pub roles: Vec, +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn invite_request_serializes_graph_shape() { + let req = DriveInviteRequest { + recipients: vec![ + DriveRecipient { + object_id: Some("oid-1".into()), + email: None, + }, + DriveRecipient { + object_id: None, + email: Some("a@example.com".into()), + }, + ], + roles: vec!["read".into()], + require_sign_in: true, + send_invitation: false, + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "recipients": [{"objectId": "oid-1"}, {"email": "a@example.com"}], + "roles": ["read"], + "requireSignIn": true, + "sendInvitation": false + }) + ); + } + #[test] fn drive_item_with_download_url() { let json = r#"{ From d44fa966d33a5ffd8f340375e4bc2f7df18e178f Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:48:25 +0100 Subject: [PATCH 2/3] Keep chat attachment sharing best-effort and tenant-aware --- CHANGELOG.md | 2 +- CLAUDE.md | 1 + README.md | 8 + docs/attachments-spec.md | 49 +++--- docs/auth.md | 6 + docs/command-reference.md | 2 +- docs/man/teams.1 | 10 ++ src/api/chats.rs | 2 +- src/api/resolve.rs | 1 + src/api/users.rs | 6 +- src/cli/message_media.rs | 352 ++++++++++++++++++++++++++++++++++---- src/models/member.rs | 4 + tests/cli.rs | 18 ++ 13 files changed, 399 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cabc6e3..b950cea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ - Windows builds reserve an 8 MiB main-thread stack, matching Linux and macOS. Windows gives the main thread 1 MiB by default, and building clap's command tree for this many subcommands needs almost all of it in an unoptimized build, so any addition to the `message` command made every debug and test invocation of `teams` on Windows — `--help` included — fail with `thread 'main' has overflowed its stack`, and `cargo test` failed on `windows-latest` while passing on Linux and macOS. A build script now passes `/STACK:8388608` to the MSVC linker (`--stack` on the GNU toolchain). The reservation is address space rather than committed memory, so an idle process costs nothing extra. - Plain lists retain optional fields that first appear after the first row, including message subjects. Human message lists include a Subject column; JSON still omits absent subjects. - `message list` and `message get` no longer drop the `subject` of a message. The `ChatMessage` model had no `subject` field, so a channel root message's subject — returned by Graph on both reads — silently vanished from every output: a message posted with a subject read back without one. Messages without a subject are unchanged and gain no `"subject": null` noise. -- `teams message send --chat … --attach FILE` now shares each uploaded file with the chat's members. The upload lands in the sender's OneDrive (`Microsoft Teams Chat Files`), where nobody else has access; the Teams client grants every member read permission when it attaches a file, but the CLI did not, so recipients got "you don't have permission" when they opened the attachment. After each upload the CLI now lists the chat's members and grants them read access via the drive item's `invite` action (no notification email), addressing them by Entra object ID where the membership carries one, else by email, and skipping the sender. If that step fails — for example when the token cannot list chat members — the upload and the message still go through and a warning on stderr says the file must be shared from OneDrive by hand. Channel attachments are unaffected: they live in the team's SharePoint library, which channel members can already read. +- `teams message send --chat … --attach FILE` attempts to grant chat members read access to the uploaded OneDrive file without notification email. Recipient lookup and sharing failures warn on stderr and allow upload/send to continue. Object IDs are used only when the roster confirms the sender and recipient share a tenant; other members use email, and members without a usable address are reported for manual sharing. Channel attachments are unchanged. ## v0.6.0 - 2026-08-30 diff --git a/CLAUDE.md b/CLAUDE.md index 7ecd2a1..14a103e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,7 @@ CLI flags > env vars (TEAMS_CLI_CLIENT_ID, TEAMS_CLI_CLIENT_SECRET, TEAMS_CLI_TE - Profile index tracked in keyring for `auth list` ### Graph API Client +- Chat attachment sharing is best-effort, including recipient lookup failures. Use object IDs only for recipients whose roster tenant matches the sender's; otherwise use email or warn about manual sharing. - Automatic retry with exponential backoff on 429/5xx - Respects `Retry-After` header for rate limiting - Pagination via `@odata.nextLink` with `--all-pages` flag diff --git a/README.md b/README.md index 64968e4..d6be8f0 100644 --- a/README.md +++ b/README.md @@ -398,6 +398,14 @@ teams message unpin --team --channel --pinned-message-id `message list --message-id ROOT_MESSAGE_ID` lists the replies under that channel thread root. The global `--page-size` and `--all-pages` options apply to the replies collection. +`message send --chat CHAT_ID --attach FILE` uploads to your OneDrive and attempts +to share the file with the chat's other members without notification email. +Discovery needs `User.Read` and a chat-member read scope such as `Chat.ReadBasic`; +upload and sharing need `Files.ReadWrite`. Object IDs are used only for a tenant +confirmed to match the sender's; other recipients use email. Lookup or sharing +failures warn on stderr and allow upload/send to continue. Members without a +usable address are reported for manual sharing from OneDrive. + The `--mention USER` flag on `message send` and `message reply` tags a person as a real Teams @mention — the kind that pings them, not literal `@Name` text. The flag is repeatable and `USER` may be an Entra object ID or UPN; the display name is resolved through Microsoft Graph. It works for chat sends, diff --git a/docs/attachments-spec.md b/docs/attachments-spec.md index 9be841b..f8c9401 100644 --- a/docs/attachments-spec.md +++ b/docs/attachments-spec.md @@ -330,7 +330,7 @@ Delegated scopes, per operation: | Read a pasted screenshot | `message attachments download` | `ChatMessage.Read` / `ChannelMessage.Read.All` | none | bytes come through the message (`hostedContents/$value`) | | Send a pasted screenshot | `message send --image` | `ChatMessage.Send` / `ChannelMessage.Send` | none | bytes travel inside the message create call | | Download an attached file | `message attachments download` | (same as reading the message) | `Files.Read.All` | the file lives in someone's OneDrive / a team's SharePoint; reading it is a drive read | -| Attach a file to a chat message | `message send --attach --chat` | `ChatMessage.Send` | `Files.ReadWrite` | the CLI must first upload the file into *your* OneDrive (`Microsoft Teams Chat Files`) | +| Attach a file to a chat message | `message send --attach --chat` | `ChatMessage.Send` | `Files.ReadWrite` | uploads into *your* OneDrive (`Microsoft Teams Chat Files`); automatic sharing also needs `User.Read` and a chat-member read scope such as `Chat.ReadBasic` | | Attach a file to a channel message | `message send --attach --team/--channel` | `ChannelMessage.Send` | `Files.ReadWrite.All` | the CLI must first upload into the *team's* SharePoint library, which is not your drive — hence the broader `.All` | Two consequences worth internalizing: @@ -401,30 +401,31 @@ Graph rewrites the relative `src` into a permanent hosted-content URL on deliver Application (app-only) tokens cannot send hosted contents; the CLI already requires delegated auth for all message mutation, so nothing changes. -**File attachments** are a two-step dance: - -1. `PUT` the bytes into the right drive (chat → `/me/drive/root:/Microsoft Teams Chat - Files/{name}:/content`, channel → the team drive folder that `filesFolder` reports, - same as `file upload`). The response is a `driveItem`. -2. Send the message with an `attachments` entry whose `id` is **the GUID inside the +**File attachments** use this sequence: + +1. For chats, read `/me` and all pages of `/chats/{id}/members` once per send to + discover recipients. This needs `User.Read` and a chat-member read scope such as + `Chat.ReadBasic`. If either lookup or a later page fails, warn on stderr and + continue uploading/sending without automatic sharing. +2. `PUT` each file into the right drive (chat → `/me/drive/root:/Microsoft Teams Chat + Files/{name}:/content`, channel → the team drive folder reported by `filesFolder`). + The response is a `driveItem`. +3. For chats, attempt to share each uploaded file using + `POST /me/drive/items/{id}/invite` with `roles: ["read"]`, `requireSignIn: true`, + and `sendInvitation: false`. This needs the same `Files.ReadWrite` scope as the + upload. Exclude the sender; use a member's object ID only when the roster confirms + the member and sender have the same tenant. Foreign or unknown tenants use email. + A missing usable address or a failed grant warns that manual OneDrive sharing + is needed, without stopping the send. Tenant sharing policy still applies. + Channel uploads rely on the channel's existing SharePoint permissions. +4. Send the message with an `attachments` entry whose `id` is **the GUID inside the driveItem's `eTag`** (e.g. `"{5FF69C5F-...},2"` → `5FF69C5F-...`), `contentType` - `"reference"`, `contentUrl` = the driveItem's `webUrl`, `name` = its `name` — plus - an `` tag in the body HTML, which is what makes - the attachment card render in clients. -3. **Chats only: share the file with the members.** The upload sits in the sender's own - OneDrive, where nobody else has access, and the message merely links to it. The Teams - client grants every chat member read permission when it attaches a file; the CLI does - the same via `POST /me/drive/items/{id}/invite` with `roles: ["read"]`, - `requireSignIn: true` and `sendInvitation: false`, addressing each member of - `GET /chats/{id}/members` (minus the sender) by Entra object ID, or by email when the - membership carries no ID. Without this step recipients get "you don't have - permission" when they open the attachment (observed live, 2026-09-03). The grant is - best-effort: if it fails the message is still sent and a warning on stderr says to - share the file from OneDrive by hand. Channel uploads need none of this — they live in - the team's SharePoint library, which channel members already read. - -Simple upload caps at 4 MB (the existing `MAX_UPLOAD_SIZE`); larger files need the -upload-session API, which is out of scope here — the CLI errors clearly instead. + `"reference"`, `contentUrl` = the driveItem's `webUrl`, and `name` = its `name`. + Include an `` tag in the body HTML so the + attachment card renders in clients. + +Simple upload caps at 250 MB; larger files need the upload-session API, which +is out of scope here — the CLI errors clearly instead. Hosted contents ride a single JSON request, so images are capped at 3 MB each to stay under Graph's 4 MB request limit after base64 expansion (+33%). diff --git a/docs/auth.md b/docs/auth.md index 4a820be..388bce3 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -109,6 +109,12 @@ consent). Note that Graph masks drives the token cannot see as 404 rather than 403, so a "not found" from `--attach` usually means the missing scope, not a missing file. +Automatic sharing of chat attachments also reads `/me` and the chat's membership, +requiring `User.Read` and a chat-member read scope such as `Chat.ReadBasic`. +The invite itself uses the upload's `Files.ReadWrite` scope. Lookup and sharing +failures warn on stderr and let the upload/message continue; share the file from +OneDrive by hand when automatic sharing cannot complete. + Future features may need additional consent. ## Login options diff --git a/docs/command-reference.md b/docs/command-reference.md index 0371a05..0c8b5c7 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -134,7 +134,7 @@ Normal message mutation requires delegated auth. App-only/client-credentials tok `message update` edits your own message in place (Graph lets a delegated caller change any property except `policyViolation`); channel edits need the `ChannelMessage.ReadWrite` delegated scope, chat edits need `Chat.ReadWrite`. Graph returns no content on success, so the command reads the message back and prints it; if that read fails the edit has still been applied and the output is `{"id": ..., "updated": true, "readBackError": ...}`. -`--image` sends a picture the way pasting a screenshot does — the bytes travel inside the message itself (a Graph "hosted content"), so it needs no scopes beyond sending messages. `--attach` uploads the file to real storage first (your OneDrive's `Microsoft Teams Chat Files` for chats, the team's SharePoint library for channels) and links it from the message; that upload needs `Files.ReadWrite` (chats) or `Files.ReadWrite.All` (channels). For chats, the CLI then grants every other chat member read access to the uploaded file (the drive item's `invite` action, no email), because a file in your own OneDrive is otherwise unreadable to them — the Teams client does the same when you attach a file. If that grant fails the message is still sent and a warning on stderr tells you to share the file from OneDrive by hand. Both flags repeat for multiple files, and `--body` becomes optional when either is present. Inline images are capped at 3MB each; attachments use Graph's 250MB simple-upload limit. +`--image` sends a picture the way pasting a screenshot does — the bytes travel inside the message itself (a Graph "hosted content"), so it needs no scopes beyond sending messages. `--attach` uploads the file to real storage first (your OneDrive's `Microsoft Teams Chat Files` for chats, the team's SharePoint library for channels) and links it from the message; that upload needs `Files.ReadWrite` (chats) or `Files.ReadWrite.All` (channels). For chats, the CLI attempts to grant the other members read access with the drive item's `invite` action (no notification email). Recipient discovery also needs `User.Read` and a chat-member read scope such as `Chat.ReadBasic`. Object IDs are used only when the roster confirms the sender and recipient share a tenant; foreign or unknown tenants use email. Lookup failures, members without a usable address, and grant failures warn on stderr with manual-sharing guidance; lookup or sharing failures do not stop upload/send. Both flags repeat for multiple files, and `--body` becomes optional when either is present. Inline images are capped at 3MB each; attachments use Graph's 250MB simple-upload limit. `--mention USER` tags a person as a real Teams @mention (the kind that pings them) in chat sends, channel sends, and channel replies. It is repeatable, and `USER` may be an Entra object ID or a UPN — the CLI resolves the display name through Microsoft Graph. A mention needs an HTML body plus a synchronized `mentions` array; the CLI builds both for you: a plain-text body is safely converted to HTML (escaped, line breaks preserved as `
`) and the `` elements are prepended to your body in flag order. A mention by itself counts as a body, so `--mention USER` without `--body` works. Raw `` markup typed directly into an HTML body is rejected with exit code 2 before anything is sent, because Graph does not turn it into a real mention. diff --git a/docs/man/teams.1 b/docs/man/teams.1 index 91d3358..0797254 100644 --- a/docs/man/teams.1 +++ b/docs/man/teams.1 @@ -222,6 +222,16 @@ and Human message lists include a Subject column. Plain lists include columns present in any message, with blank cells for missing values, so subjects do not depend on the first message having a title. JSON omits absent subjects. +.PP +.B message send --chat CHAT_ID --attach FILE +uploads into the sender's OneDrive and attempts to grant the chat's other +members read access without notification email. Discovery needs User.Read +and a chat-member read scope such as Chat.ReadBasic; upload and sharing +need Files.ReadWrite. Object IDs are used only when the roster confirms +the sender and recipient share a tenant; other recipients use email. +Lookup failures, members without a usable address, and grant failures +warn on stderr with manual OneDrive sharing guidance. Lookup or sharing +failures do not stop upload/send. .SH CHAT COMMANDS .nf teams chat list diff --git a/src/api/chats.rs b/src/api/chats.rs index 895a64d..caebe1b 100644 --- a/src/api/chats.rs +++ b/src/api/chats.rs @@ -39,7 +39,7 @@ pub async fn list_members( /// `GET /chats/{id}/members` doesn't support the `$top` OData query option /// (Graph returns HTTP 400), so page via `@odata.nextLink` only. -async fn list_members_at( +pub(crate) async fn list_members_at( client: &GraphClient, url: &str, pagination: &PaginationOpts, diff --git a/src/api/resolve.rs b/src/api/resolve.rs index d080818..181ef62 100644 --- a/src/api/resolve.rs +++ b/src/api/resolve.rs @@ -471,6 +471,7 @@ mod tests { display_name: Some("Jane von Smith".into()), roles: None, user_id: Some("u1".into()), + tenant_id: None, email: Some("JSmith@example.com".into()), }; assert_eq!( diff --git a/src/api/users.rs b/src/api/users.rs index b679e20..941a0e4 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -5,7 +5,11 @@ use super::client::{GraphClient, PaginationOpts}; use super::endpoints; pub async fn get_me(client: &GraphClient) -> Result { - client.get(&endpoints::me(), &[]).await + get_me_at(client, &endpoints::me()).await +} + +pub(crate) async fn get_me_at(client: &GraphClient, url: &str) -> Result { + client.get(url, &[]).await } pub async fn get_user(client: &GraphClient, id: &str) -> Result { diff --git a/src/cli/message_media.rs b/src/cli/message_media.rs index 2bb7574..10b28b6 100644 --- a/src/cli/message_media.rs +++ b/src/cli/message_media.rs @@ -56,7 +56,7 @@ pub async fn apply_media( // each uploaded file can be shared with them below. let chat_recipients = match dest { AttachDestination::Chat { chat_id } if !attaches.is_empty() => { - Some(chat_recipients(client, chat_id).await?) + Some(chat_recipients(client, chat_id).await) } _ => None, }; @@ -109,44 +109,120 @@ pub async fn apply_media( /// other than the sender. The Teams client grants these permissions itself /// when a file is attached; a bare drive upload does not, so without this step /// recipients get "you don't have permission" when they open the file. -async fn chat_recipients(client: &GraphClient, chat_id: &str) -> Result> { - let me = api::users::get_me(client).await?; - let members = api::chats::list_members( +async fn chat_recipients(client: &GraphClient, chat_id: &str) -> Vec { + chat_recipients_at( client, - chat_id, - &PaginationOpts { - page_size: 50, - all_pages: true, - }, + &api::endpoints::me(), + &api::endpoints::chat_members(chat_id), ) - .await?; - Ok(invite_recipients(&members, me.id.as_deref())) + .await } -/// Members addressed by Entra object ID when the membership carries one (it -/// also covers accounts with no mail attribute), else by email; the sender and -/// members with neither are skipped, and duplicates collapse. +async fn chat_recipients_at( + client: &GraphClient, + me_url: &str, + members_url: &str, +) -> Vec { + let lookup: Result<_> = async { + let me = api::users::get_me_at(client, me_url).await?; + let members = api::chats::list_members_at( + client, + members_url, + &PaginationOpts { + page_size: 50, + all_pages: true, + }, + ) + .await?; + Ok(invite_recipients(&members, me.id.as_deref())) + } + .await; + match lookup { + Ok(selection) => { + if selection.skipped > 0 { + tracing::warn!( + "Could not identify {} chat member(s) for file sharing; share the attachments \ + with them from OneDrive by hand.", + selection.skipped + ); + } + selection.recipients + } + Err(error) => { + tracing::warn!( + "Could not look up the chat's file-sharing recipients ({error}); \ + continuing without automatic sharing. Share the attachments from OneDrive by hand." + ); + Vec::new() + } + } +} + +struct RecipientSelection { + recipients: Vec, + skipped: usize, +} + +/// An object ID is usable only in the sender's directory. Establish that +/// directory from the sender's roster entry, use email for foreign or unknown +/// tenants, and report members without a usable address for manual sharing. fn invite_recipients( members: &[ConversationMember], sender_id: Option<&str>, -) -> Vec { - let mut seen = std::collections::HashSet::new(); - members +) -> RecipientSelection { + let sender_id = nonempty(sender_id); + let is_sender = |member: &ConversationMember| { + sender_id + .zip(nonempty(member.user_id.as_deref())) + .is_some_and(|(sender, id)| sender.eq_ignore_ascii_case(id)) + }; + let sender_tenant = members .iter() - .filter(|m| m.user_id.as_deref() != sender_id || sender_id.is_none()) - .filter_map(|m| match (&m.user_id, &m.email) { - (Some(id), _) => Some(DriveRecipient { - object_id: Some(id.clone()), - email: None, - }), - (None, Some(email)) => Some(DriveRecipient { - object_id: None, - email: Some(email.clone()), - }), - (None, None) => None, - }) - .filter(|r| seen.insert(r.clone())) - .collect() + .find(|member| is_sender(member)) + .and_then(|member| nonempty(member.tenant_id.as_deref())); + let mut seen = std::collections::HashSet::new(); + let mut selection = RecipientSelection { + recipients: Vec::new(), + skipped: 0, + }; + for member in members.iter().filter(|member| !is_sender(member)) { + let same_tenant = sender_tenant + .zip(nonempty(member.tenant_id.as_deref())) + .is_some_and(|(sender, tenant)| sender.eq_ignore_ascii_case(tenant)); + let recipient = + if let Some(id) = nonempty(member.user_id.as_deref()).filter(|_| same_tenant) { + Some(( + format!("id:{}", id.to_ascii_lowercase()), + DriveRecipient { + object_id: Some(id.to_string()), + email: None, + }, + )) + } else { + nonempty(member.email.as_deref()).map(|email| { + ( + format!("email:{}", email.to_ascii_lowercase()), + DriveRecipient { + object_id: None, + email: Some(email.to_string()), + }, + ) + }) + }; + match recipient { + Some((key, recipient)) => { + if seen.insert(key) { + selection.recipients.push(recipient); + } + } + None => selection.skipped += 1, + } + } + selection +} + +fn nonempty(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) } /// Share an uploaded chat file with the chat's members. A failure here is @@ -349,6 +425,7 @@ mod tests { display_name: None, roles: None, user_id: user_id.map(str::to_string), + tenant_id: Some("tenant-local".to_string()), email: email.map(str::to_string), } } @@ -362,9 +439,10 @@ mod tests { member(None, Some("mail-only@example.com")), member(None, None), ]; - let recipients = invite_recipients(&members, Some("me")); + let selection = invite_recipients(&members, Some("me")); + assert_eq!(selection.skipped, 1); assert_eq!( - recipients, + selection.recipients, vec![ DriveRecipient { object_id: Some("u1".into()), @@ -377,7 +455,213 @@ mod tests { ] ); // Unknown sender: nobody is skipped on that basis. - assert_eq!(invite_recipients(&members[..2], None).len(), 2); + assert_eq!(invite_recipients(&members[..2], None).recipients.len(), 2); + } + + #[test] + fn foreign_and_unknown_tenants_use_email_instead_of_object_id() { + let mut external = member(Some("foreign-id"), Some("external@example.test")); + external.tenant_id = Some("tenant-foreign".into()); + let mut unknown = member(Some("unknown-id"), Some("unknown@example.test")); + unknown.tenant_id = None; + let members = [member(Some("me"), None), external, unknown]; + let selection = invite_recipients(&members, Some("me")); + assert_eq!(selection.skipped, 0); + assert_eq!( + selection.recipients, + vec![ + DriveRecipient { + object_id: None, + email: Some("external@example.test".into()) + }, + DriveRecipient { + object_id: None, + email: Some("unknown@example.test".into()) + }, + ] + ); + } + + #[test] + fn members_without_a_usable_address_are_counted_for_a_warning() { + let mut foreign = member(Some("foreign-id"), None); + foreign.tenant_id = Some("tenant-foreign".into()); + let mut unknown = member(Some("unknown-id"), Some(" ")); + unknown.tenant_id = None; + let members = [member(Some("me"), None), foreign, unknown]; + let selection = invite_recipients(&members, Some("me")); + assert!(selection.recipients.is_empty()); + assert_eq!(selection.skipped, 2); + // A missing sender tenant also makes another member's ID insufficient. + let members = [member(Some("local-id"), None)]; + assert_eq!(invite_recipients(&members, None).skipped, 1); + } + + #[test] + fn recipient_selection_normalizes_empty_values_and_duplicate_addresses() { + let mut sender = member(Some("ME"), None); + sender.tenant_id = Some("TENANT-LOCAL".into()); + let members = [ + sender, + member(Some("U1"), None), + member(Some("u1"), None), + member(Some(" "), Some(" A@example.test ")), + member(None, Some("a@example.test")), + ]; + let selection = invite_recipients(&members, Some("me")); + assert_eq!(selection.skipped, 0); + assert_eq!( + selection.recipients, + vec![ + DriveRecipient { + object_id: Some("U1".into()), + email: None + }, + DriveRecipient { + object_id: None, + email: Some("A@example.test".into()) + }, + ] + ); + } + + fn test_client() -> GraphClient { + GraphClient::new( + crate::auth::token::TokenInfo { + access_token: "synthetic-test-token".into(), + expires_at: None, + token_type: "Bearer".into(), + scope: None, + refresh_token: None, + profile: "test".into(), + }, + &crate::config::NetworkConfig { + timeout: 3, + max_retries: 0, + retry_backoff_base: 1, + }, + ) + .unwrap() + } + + #[tokio::test] + async fn discovery_failures_on_me_members_or_later_pages_are_not_fatal() { + use wiremock::matchers::{method, path, query_param_is_missing}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + for failure in ["me", "members", "page2"] { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/me")) + .respond_with(if failure == "me" { + ResponseTemplate::new(403) + } else { + ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "me"})) + }) + .expect(1) + .mount(&server) + .await; + if failure != "me" { + Mock::given(method("GET")) + .and(path("/members")) + .and(query_param_is_missing("$top")) + .respond_with(if failure == "members" { + ResponseTemplate::new(403) + } else { + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "value": [{"userId": "me", "tenantId": "local"}, + {"userId": "u1", "tenantId": "local"}], + "@odata.nextLink": format!("{}/page2", server.uri()) + })) + }) + .expect(1) + .mount(&server) + .await; + } + if failure == "page2" { + Mock::given(method("GET")) + .and(path("/page2")) + .respond_with(ResponseTemplate::new(403)) + .expect(1) + .mount(&server) + .await; + } + let recipients = chat_recipients_at( + &test_client(), + &format!("{}/me", server.uri()), + &format!("{}/members", server.uri()), + ) + .await; + assert!( + recipients.is_empty(), + "failure at {failure} used incomplete recipient data" + ); + let expected_requests = match failure { + "me" => 1, + "members" => 2, + _ => 3, + }; + assert_eq!( + server.received_requests().await.unwrap().len(), + expected_requests + ); + } + } + + #[tokio::test] + async fn discovery_follows_all_pages_and_keeps_tenant_information() { + use wiremock::matchers::{method, path, query_param_is_missing}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/me")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id":"me"}))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/members")) + .and(query_param_is_missing("$top")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "value": [{"userId": "local-id", "tenantId": "local"}], + "@odata.nextLink": format!("{}/page2", server.uri()) + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")).and(path("/page2")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "value": [{"userId": "me", "tenantId": "local"}, + {"userId": "foreign-id", "tenantId": "foreign", "email": "external@example.test"}, + {"userId": "local-id", "tenantId": "local"}] + }))).expect(1).mount(&server).await; + let recipients = chat_recipients_at( + &test_client(), + &format!("{}/me", server.uri()), + &format!("{}/members", server.uri()), + ) + .await; + assert_eq!( + recipients, + vec![ + DriveRecipient { + object_id: Some("local-id".into()), + email: None + }, + DriveRecipient { + object_id: None, + email: Some("external@example.test".into()) + }, + ] + ); + } + + #[test] + fn discovery_endpoint_builders_use_graph_v1() { + assert_eq!(api::endpoints::me(), "https://graph.microsoft.com/v1.0/me"); + assert_eq!( + api::endpoints::chat_members("chat-id"), + "https://graph.microsoft.com/v1.0/chats/chat-id/members" + ); } #[test] diff --git a/src/models/member.rs b/src/models/member.rs index b73687e..55a38d2 100644 --- a/src/models/member.rs +++ b/src/models/member.rs @@ -13,6 +13,8 @@ pub struct ConversationMember { #[serde(rename = "userId", skip_serializing_if = "Option::is_none")] pub user_id: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub tenant_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub email: Option, } @@ -62,9 +64,11 @@ mod tests { "displayName": "Alice", "roles": ["owner"], "userId": "u1", + "tenantId": "tenant-1", "email": "alice@example.com" }); let member: ConversationMember = serde_json::from_value(json).unwrap(); + assert_eq!(member.tenant_id.as_deref(), Some("tenant-1")); assert_eq!(member.display_name.as_deref(), Some("Alice")); assert_eq!(member.roles.as_ref().unwrap()[0], "owner"); } diff --git a/tests/cli.rs b/tests/cli.rs index 1d73860..f929bba 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -770,6 +770,24 @@ fn message_list_rejects_message_id_with_chat() { .stderr(predicate::str::contains("cannot be used with")); } +#[test] +fn documented_chat_attachment_command_parses_before_authentication() { + teams() + .args([ + "message", + "send", + "--chat", + "chat-id", + "--attach", + "example.txt", + "--output", + "json", + ]) + .assert() + .code(3) + .stdout(predicate::str::contains("auth login")); +} + #[test] fn message_documented_flags_are_available() { teams() From 261c14b361cdc4e2b444c325c5bb11fd3cefc6a0 Mon Sep 17 00:00:00 2001 From: Sion Smith Date: Sun, 6 Sep 2026 06:59:25 +0100 Subject: [PATCH 3/3] Restore the changelog's account of the chat sharing fix The best-effort follow-up shortened the entry to the mechanism and lost the symptom it fixes. Keep the symptom, the invite mechanics, the tenant rule and the best-effort behaviour in one entry. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WG7vFLjFZHqAUMRS1zkWRE --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b950cea..b1425a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ - Windows builds reserve an 8 MiB main-thread stack, matching Linux and macOS. Windows gives the main thread 1 MiB by default, and building clap's command tree for this many subcommands needs almost all of it in an unoptimized build, so any addition to the `message` command made every debug and test invocation of `teams` on Windows — `--help` included — fail with `thread 'main' has overflowed its stack`, and `cargo test` failed on `windows-latest` while passing on Linux and macOS. A build script now passes `/STACK:8388608` to the MSVC linker (`--stack` on the GNU toolchain). The reservation is address space rather than committed memory, so an idle process costs nothing extra. - Plain lists retain optional fields that first appear after the first row, including message subjects. Human message lists include a Subject column; JSON still omits absent subjects. - `message list` and `message get` no longer drop the `subject` of a message. The `ChatMessage` model had no `subject` field, so a channel root message's subject — returned by Graph on both reads — silently vanished from every output: a message posted with a subject read back without one. Messages without a subject are unchanged and gain no `"subject": null` noise. -- `teams message send --chat … --attach FILE` attempts to grant chat members read access to the uploaded OneDrive file without notification email. Recipient lookup and sharing failures warn on stderr and allow upload/send to continue. Object IDs are used only when the roster confirms the sender and recipient share a tenant; other members use email, and members without a usable address are reported for manual sharing. Channel attachments are unchanged. +- `teams message send --chat … --attach FILE` now shares each uploaded file with the chat's other members. The upload lands in the sender's OneDrive (`Microsoft Teams Chat Files`), where nobody else has access; the Teams client grants every member read permission when it attaches a file, but the CLI did not, so recipients got "you don't have permission" when they opened the attachment. After each upload the CLI now grants the members read access through the drive item's `invite` action, with no notification email. A member is addressed by Entra object ID only when the roster shows the same tenant as the sender — Graph documents that a chat's membership can span tenants, and an object ID means nothing outside its own directory — and by email otherwise; the sender is skipped. Every step is best-effort: a failed member lookup, a member with no usable address, or a refused grant warns on stderr and says to share the file from OneDrive by hand, and the upload and the message still go through. Channel attachments are unchanged: they live in the team's SharePoint library, which channel members already read. ## v0.6.0 - 2026-08-30