Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,14 @@ teams message unpin --team <team-id> --channel <channel-id> --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,
Expand Down
36 changes: 24 additions & 12 deletions docs/attachments-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -401,19 +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 `<attachment id="{guid}"></attachment>` tag in the body HTML, which is what makes
the attachment card render in clients.
`"reference"`, `contentUrl` = the driveItem's `webUrl`, and `name` = its `name`.
Include an `<attachment id="{guid}"></attachment>` tag in the body HTML so the
attachment card renders in clients.

Simple upload caps at 4&nbsp;MB (the existing `MAX_UPLOAD_SIZE`); larger files need the
upload-session API, which is out of scope here — the CLI errors clearly instead.
Simple upload caps at 250&nbsp;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&nbsp;MB each to
stay under Graph's 4&nbsp;MB request limit after base64 expansion (+33%).

Expand Down
6 changes: 6 additions & 0 deletions docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 `<br>`) and the `<at>` elements are prepended to your body in flag order. A mention by itself counts as a body, so `--mention USER` without `--body` works. Raw `<at>` 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.

Expand Down
10 changes: 10 additions & 0 deletions docs/man/teams.1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/api/chats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/api/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
26 changes: 25 additions & 1 deletion src/api/files.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<DriveRecipient>,
) -> Result<Vec<DrivePermission>> {
let req = DriveInviteRequest {
recipients,
roles: vec!["read".to_string()],
require_sign_in: true,
send_invitation: false,
};
let resp: PageResponse<DrivePermission> = 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,
Expand Down
1 change: 1 addition & 0 deletions src/api/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
6 changes: 5 additions & 1 deletion src/api/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ use super::client::{GraphClient, PaginationOpts};
use super::endpoints;

pub async fn get_me(client: &GraphClient) -> Result<User> {
client.get(&endpoints::me(), &[]).await
get_me_at(client, &endpoints::me()).await
}

pub(crate) async fn get_me_at(client: &GraphClient, url: &str) -> Result<User> {
client.get(url, &[]).await
}

pub async fn get_user(client: &GraphClient, id: &str) -> Result<User> {
Expand Down
2 changes: 1 addition & 1 deletion src/cli/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
Loading