Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3847108
Report token store read failures instead of hiding them
aberoham Oct 11, 2026
ba2454e
Honour --config in auth switch and config path; save atomically
aberoham Oct 11, 2026
6626b11
Limit new profile names to a file-safe character set
aberoham Oct 11, 2026
ca0b0eb
Refuse a new profile name that shares an existing token file
aberoham Oct 11, 2026
0c15e50
Compare profile token file names without regard to case
aberoham Oct 11, 2026
f0a8375
Report user, tenant and store errors in auth status
aberoham Oct 11, 2026
0c0ed40
Report pagination continuation in list metadata
aberoham Oct 11, 2026
d321216
Write downloads through an exclusive temp file
aberoham Oct 11, 2026
9c39520
Add message attachments delete for OneDrive uploads
aberoham Oct 11, 2026
83a4c79
Merge main and preserve storage and request changelog entries
sionsmith Oct 15, 2026
f5794c6
Test empty pages and opaque Graph continuation tokens
sionsmith Oct 15, 2026
fb7173e
Skip remote drive references when deleting attachment uploads
sionsmith Oct 15, 2026
11deee3
Merge main and retain request safety and pagination tests
sionsmith Oct 15, 2026
0481539
Merge main and preserve attachment and request changelog entries
sionsmith Oct 15, 2026
d2baf23
Integrate reviewed storage changes for combined validation
sionsmith Oct 15, 2026
186be55
Integrate reviewed pagination changes for combined validation
sionsmith Oct 15, 2026
d9a784d
Integrate reviewed attachment changes for combined validation
sionsmith Oct 15, 2026
a122e10
Resolve changelog against merged storage and pagination PRs
sionsmith Oct 15, 2026
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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Added

- List commands without `--all-pages` report whether the result is complete: JSON `metadata.has_more`, plus `metadata.next_link` (Graph's `@odata.nextLink`) when more pages exist. Previously a truncated page looked identical to a complete collection. With `--all-pages`, `has_more` is false. `data` is unchanged. Human and plain output print "More results available; use --all-pages" on stderr.
- `teams message attachments delete (--chat C | --team T --channel CH [--reply R]) --message M [--index N] --yes` deletes files attached with `message send --attach` from the sender's OneDrive. Each `reference` attachment is resolved through Graph's shares API and deleted only when it is in the caller's own drive; inline images, other people's files and channel files are skipped with a reason. A file already gone counts as deleted. The message and its file card are left as they are; `message delete` is separate.

### Changed

Expand All @@ -15,14 +16,15 @@
- `teams team update` and `teams channel update` no longer fail with "Failed to parse API response" (exit 1) after a successful update. Graph answers these with 204 No Content; every PATCH-based update now reads the resource back when the response has no body, so the output is unchanged. If that read fails, the error says the update succeeded (exit 1).
- A POST is no longer replayed after a 5xx or a network failure once sent, which could post a message twice. It fails with exit code 8 or 7 and a hint to check before re-sending. A 429 or a connection that was never made is still retried, and read-only POSTs (`search`, `presence get-batch`) retry as before.
- A POST answered 2xx whose response body cannot be read or parsed now says Graph performed it and not to re-send, instead of a bare network or parse error.

- `teams auth status` reports `user` and `tenant_id` at the top level, as `auth list` does, not only inside `token_diagnostics`. A token store that cannot be read is now `KEYRING_ERROR` (exit code 10) instead of `authenticated: false`, and a signed-out profile says why in `reason`.
- A token store that cannot be read (a locked keychain, a denied prompt, an SSH session) is reported as `KEYRING_ERROR` (exit code 10) with its cause, instead of "Not authenticated" (exit code 3). Only a profile with no stored token is "Not authenticated".
- A profile index that cannot be read or parsed is an error (exit code 10) instead of an empty list. `auth login` no longer rewrites it to hold only the new profile, orphaning the rest; `auth list` and `auth logout --all` no longer report no profiles as a success.
- `teams auth switch` with `--config` wrote the selected profile to the default config file, overwriting it with the `--config` file's contents and leaving that file unchanged. It now saves to the `--config` file, and a failed save is a config error (exit code 10) instead of a warning beside a success.
- `teams config path` ignored `--config` and always reported the default path. It now reports the file in use, with `source` set to `flag` or `default`.
- Config saves (`config set`, `config init`, `auth switch`) truncated the file and wrote it in place, so a failed write could leave a partial config. They now write a temporary file and rename it over the original, as `auth login` already did.
- A new profile's name is limited to 1-64 of `A-Z a-z 0-9 . _ -`, not `.` or `..`. `TEAMS_CLI_TOKEN_STORE=file` maps `:`, `/` and `\` to `.` in token file names, so `customer/a` and `customer.a` shared one token and could run as the wrong account. `auth login`, `config set profiles.<name>...` and `config set default.profile` refuse another name as invalid input (exit code 2). Profiles already in the config or the token index keep working under their names. A new name whose token file name matches an existing profile's, ignoring case, is refused too.
- Attachment deletion also rejects `remoteItem` references, including local shortcuts into another drive.
- `message attachments download` and `file download --path` write through a uniquely named, exclusively created temp file in the target directory, then rename it into place. Attachments used a fixed `<target>.part`, so a symlink there redirected the write, a stray `.part` was clobbered, and concurrent downloads to one target shared a temp file; `file download` wrote in place, through any symlink at the target, and left a truncated file on failure; a symlink at the target is now replaced. A failed write now leaves no temp file. An existing target is still overwritten and keeps its mode, and `message attachments download --path -` still streams to stdout.

## v0.9.0 - 2026-10-04

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Single-crate Rust binary. Key modules:
- `auth.rs` — login (PKCE, device code, client credentials), status, list, switch, logout, token
- `team.rs` — list, get, create, update, delete, clone, archive, unarchive, members
- `channel.rs` — list, get, create, update, delete, members
- `message.rs` — send, list, get, reply, update, delete, undelete, react, unreact, pin, unpin
- `message.rs` — send, list, get, reply, update, delete, undelete, react, unreact, pin, unpin; attachments list/download/delete (delete requires --yes and skips remote items)
- `chat.rs` — list, get, create, hide, unhide, members
- `presence.rs` — get, set, set-preferred, clear, clear-preferred, status, get-batch
- `search.rs` — messages, users, teams
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ anyhow = "1"
urlencoding = "2"
bytes = "1"
mime_guess = "2"
tempfile = "3"

[dev-dependencies]
wiremock = "0.6"
tempfile = "3"
assert_cmd = "2"
predicates = "3"

Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ teams message list --team <team-id> --channel <channel-id>
teams message list --team <team-id> --channel <channel-id> --message-id <root-message-id> --all-pages
teams message list --chat <chat-id>
teams message get --team <team-id> --channel <channel-id> --message <msg-id>
teams message attachments delete --chat <chat-id> --message <msg-id> [--index N] --yes
teams message reply --team <team-id> --channel <channel-id> --message <msg-id> --mention <user-id-or-upn> --body "Thanks!"
teams message update --team <team-id> --channel <channel-id> --message <msg-id> --body "Corrected"
teams message update --chat <chat-id> --message <msg-id> --body "Corrected"
Expand All @@ -419,6 +420,12 @@ 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.

`message attachments delete ... --yes` removes those uploads from your OneDrive
(`--index N` for one item, numbered as in `attachments list`). Only files in
your own OneDrive are deleted; inline images, files shared by others and
channel files are skipped with a reason. The message and its file card stay;
use `message delete` for the message.

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
3 changes: 2 additions & 1 deletion docs/attachments-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,8 @@ Ordered, each step compiles and passes tests independently.
- 404 on hostedContent (expired/deleted) → exit 5, name the index and message ID.
- Partial failures download what they can, then exit non-zero with an error that names
the failed item and lists the paths already saved.
- Never write partial files: download to `{path}.part`, rename on success.
- Never write partial files: download to a uniquely named, exclusively created temp file
beside the target, rename on success.

### Security notes

Expand Down
3 changes: 3 additions & 0 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ teams message list (--team TEAM_ID --channel CHANNEL_ID [--message-id ROOT_MESSA
teams message get --team TEAM_ID --channel CHANNEL_ID (MESSAGE_ID | --message MESSAGE_ID) [--with-attachments]
teams message attachments list (--team TEAM_ID --channel CHANNEL_ID [--reply REPLY_ID] | --chat CHAT_ID) (MESSAGE_ID | --message MESSAGE_ID)
teams message attachments download (--team TEAM_ID --channel CHANNEL_ID [--reply REPLY_ID] | --chat CHAT_ID) (MESSAGE_ID | --message MESSAGE_ID) [--index N] [--dir DIR | --path FILE]
teams message attachments delete (--team TEAM_ID --channel CHANNEL_ID [--reply REPLY_ID] | --chat CHAT_ID) (MESSAGE_ID | --message MESSAGE_ID) [--index N] --yes
teams message reply --team TEAM_ID --channel CHANNEL_ID --message-id MESSAGE_ID [--body TEXT | --stdin] [--content-type text|html] [--image PATH]... [--attach PATH]... [--mention USER]...
teams message update (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID) (MESSAGE_ID | --message MESSAGE_ID) --body TEXT [--content-type text|html]
teams message delete (--team TEAM_ID --channel CHANNEL_ID [--reply REPLY_ID] | --chat CHAT_ID) (MESSAGE_ID | --message MESSAGE_ID) --yes
Expand Down Expand Up @@ -172,6 +173,8 @@ teams message send --chat CHAT_ID --quote MESSAGE_ID --body "Yes, that works."

`message attachments` unifies the two ways Teams stores message media: inline images pasted into the compose box (Graph "hosted contents") and files attached via SharePoint/OneDrive (`reference` attachments). `list` returns an indexed inventory; `download` fetches everything downloadable by default, or one item with `--index` (add `--path FILE` for an exact destination, or `--path -` to stream to stdout). Inline images and code snippets need no scopes beyond message reads; file attachments additionally require the `Files.Read.All` delegated scope. `message get --with-attachments` embeds the same inventory under `attachment_items` in the message output.

`message attachments delete` removes files you attached (`message send --attach`) from your OneDrive. Each selected `reference` item is resolved through Graph's `/shares/{token}/driveItem` and deleted only when it is a file in your own drive (`/me/drive`); inline images, code snippets, cards, files shared by others and channel files are skipped with a `reason`, and so is a file Graph cannot find or let you see. Without `--index` every item is considered; `--index N` uses the numbering of `attachments list`. Output lists `{index, name, deleted, reason?}` per item; a file already gone counts as deleted (`reason: "already deleted"`). A Graph failure on a delete is an error, naming items deleted before it. Needs `Files.ReadWrite`, and `--yes` (without it the command exits with code 2 and sends nothing). The message itself is not changed: its file card remains, now pointing at a missing file. Use `message delete` to remove the message.

## Chats

```bash
Expand Down
19 changes: 19 additions & 0 deletions docs/man/teams.1
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ teams message list (--team TEAM_ID --channel CHANNEL_ID [--message-id ROOT_MESSA
teams message get --team TEAM_ID --channel CHANNEL_ID (MESSAGE_ID | --message MESSAGE_ID) [--with-attachments]
teams message attachments list (--team TEAM_ID --channel CHANNEL_ID [--reply REPLY_ID] | --chat CHAT_ID) (MESSAGE_ID | --message MESSAGE_ID)
teams message attachments download (--team TEAM_ID --channel CHANNEL_ID [--reply REPLY_ID] | --chat CHAT_ID) (MESSAGE_ID | --message MESSAGE_ID) [--index N] [--dir DIR | --path FILE]
teams message attachments delete (--team TEAM_ID --channel CHANNEL_ID [--reply REPLY_ID] | --chat CHAT_ID) (MESSAGE_ID | --message MESSAGE_ID) [--index N] --yes
teams message reply --team TEAM_ID --channel CHANNEL_ID --message-id MESSAGE_ID [--body TEXT | --stdin] [--content-type text|html] [--image PATH]... [--attach PATH]... [--mention USER]...
teams message reply --team TEAM_ID --channel CHANNEL_ID --message MESSAGE_ID --body TEXT
teams message update (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID) (MESSAGE_ID | --message MESSAGE_ID) --body TEXT [--content-type text|html]
Expand Down Expand Up @@ -252,6 +253,24 @@ 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.
.PP
.B message attachments delete
removes files you attached from your OneDrive. Each selected reference
attachment is resolved through /shares/{token}/driveItem and deleted only
when it lives in your own drive (/me/drive). Inline images, code snippets,
cards, files shared by others, channel files and files Graph cannot find
are skipped with a reason. Without
.B --index
every item is considered;
.B --index N
uses the numbering of
.B attachments list.
A file already gone counts as deleted. Needs Files.ReadWrite and
.B --yes
(without it the command exits with code 2). The message and its file card
remain; use
.B message delete
for the message.
.SH CHAT COMMANDS
.nf
teams chat list [--order-by activity]
Expand Down
31 changes: 30 additions & 1 deletion src/api/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,11 @@ pub fn channel_files_folder(team_id: &str, channel_id: &str) -> String {
}

pub fn drive_item(drive_id: &str, item_id: &str) -> String {
format!("{GRAPH_V1}/drives/{drive_id}/items/{item_id}")
drive_item_at(GRAPH_V1, drive_id, item_id)
}

pub fn drive_item_at(base: &str, drive_id: &str, item_id: &str) -> String {
format!("{base}/drives/{drive_id}/items/{item_id}")
}

pub fn drive_item_children(drive_id: &str, item_id: &str) -> String {
Expand All @@ -350,6 +354,10 @@ pub fn drive_upload_to_folder_rename(drive_id: &str, parent_id: &str, filename:
)
}

pub fn me_drive_at(base: &str) -> String {
format!("{base}/me/drive")
}

pub fn me_drive_root_children() -> String {
format!("{GRAPH_V1}/me/drive/root/children")
}
Expand Down Expand Up @@ -456,6 +464,10 @@ pub fn shares_drive_item_content(sharing_token: &str) -> String {
format!("{GRAPH_V1}/shares/{sharing_token}/driveItem/content")
}

pub fn shares_drive_item_at(base: &str, sharing_token: &str) -> String {
format!("{base}/shares/{sharing_token}/driveItem")
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -489,6 +501,23 @@ mod tests {
);
}

#[test]
fn attachment_delete_urls() {
let url = "https://tenant-my.sharepoint.com/personal/user/Documents/Microsoft%20Teams%20Chat%20Files/NetskopeLogs.zip";
assert_eq!(
shares_drive_item_at(GRAPH_V1, &sharing_url_token(url)),
"https://graph.microsoft.com/v1.0/shares/u!aHR0cHM6Ly90ZW5hbnQtbXkuc2hhcmVwb2ludC5jb20vcGVyc29uYWwvdXNlci9Eb2N1bWVudHMvTWljcm9zb2Z0JTIwVGVhbXMlMjBDaGF0JTIwRmlsZXMvTmV0c2tvcGVMb2dzLnppcA/driveItem"
);
assert_eq!(
me_drive_at(GRAPH_V1),
"https://graph.microsoft.com/v1.0/me/drive"
);
assert_eq!(
drive_item("d1", "i1"),
"https://graph.microsoft.com/v1.0/drives/d1/items/i1"
);
}

#[test]
fn hosted_content_value_encodes_id() {
// Base64 IDs can contain '+', '/', and '=' which must be percent-encoded in a path.
Expand Down
57 changes: 55 additions & 2 deletions src/api/files.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use crate::error::{Result, TeamsError};
use crate::models::common::{Page, PageResponse};
use crate::models::file::{
DriveInviteRequest, DriveItem, DrivePermission, DriveRecipient, FilesFolder, ShareLinkRequest,
ShareLinkResponse,
Drive, DriveInviteRequest, DriveItem, DrivePermission, DriveRecipient, FilesFolder,
ShareLinkRequest, ShareLinkResponse,
};

use super::client::{GraphClient, PaginationOpts};
Expand Down Expand Up @@ -317,6 +317,59 @@ pub async fn delete_file(
.await
}

/// ID of the signed-in user's OneDrive.
pub async fn my_drive_id_at(client: &GraphClient, base: &str) -> Result<String> {
let drive: Drive = client
.get(&endpoints::me_drive_at(base), &[])
.await
.map_err(delete_scope_hint)?;
drive.id.ok_or_else(|| TeamsError::ApiError {
status: 500,
message: "/me/drive missing id".to_string(),
})
}

/// Resolve a shared web URL (an attachment's `contentUrl`) to its drive item.
pub async fn shared_drive_item_at(
client: &GraphClient,
base: &str,
content_url: &str,
) -> Result<DriveItem> {
let token = endpoints::sharing_url_token(content_url);
client
.get(&endpoints::shares_drive_item_at(base, &token), &[])
.await
}

/// Delete an item from a drive. A 404 is returned as `NotFound` unchanged so
/// the caller can treat an item that is already gone as deleted.
pub async fn delete_drive_item_at(
client: &GraphClient,
base: &str,
drive_id: &str,
item_id: &str,
) -> Result<()> {
client
.delete(&endpoints::drive_item_at(base, drive_id, item_id))
.await
.map_err(|e| match e {
TeamsError::PermissionDenied(_) => delete_scope_hint(e),
other => other,
})
}

/// Graph masks a drive the token cannot see as 404, so `/me/drive` failing
/// with 403 or 404 usually means the Files.ReadWrite scope is missing.
fn delete_scope_hint(err: TeamsError) -> TeamsError {
let hint = "\nHint: deleting attachment uploads needs the Files.ReadWrite delegated \
scope. Add it to your profile's scopes and run `teams auth refresh`.";
match err {
TeamsError::PermissionDenied(msg) => TeamsError::PermissionDenied(format!("{msg}{hint}")),
TeamsError::NotFound(msg) => TeamsError::NotFound(format!("{msg}{hint}")),
other => other,
}
}

/// 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.
Expand Down
7 changes: 1 addition & 6 deletions src/cli/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,12 +207,7 @@ pub async fn run(
let bytes = api::files::download_file(&client, &team, &channel, &file_id).await?;

if let Some(path) = path {
std::fs::write(&path, &bytes).map_err(|e| {
crate::error::TeamsError::InvalidInput(format!(
"Failed to write file '{}': {e}",
path
))
})?;
super::message_attachments::write_atomically(std::path::Path::new(&path), &bytes)?;
if format != OutputFormat::Plain {
let start = Instant::now();
let result = serde_json::json!({
Expand Down
10 changes: 9 additions & 1 deletion src/cli/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ pub enum MessageCommand {
#[arg(long)]
with_attachments: bool,
},
/// List or download message attachments and inline images
/// List, download or delete message attachments and inline images
Attachments {
#[command(subcommand)]
command: super::message_attachments::AttachmentsCommand,
Expand Down Expand Up @@ -353,6 +353,14 @@ pub async fn run(
"Deleting a message needs explicit confirmation: add --yes".into(),
));
}
if let MessageCommand::Attachments {
command: super::message_attachments::AttachmentsCommand::Delete { yes: false, .. },
} = &cmd
{
return Err(TeamsError::InvalidInput(
"Deleting attachments needs explicit confirmation: add --yes".into(),
));
}

let token = auth::resolve_token(profile).await?;
let client = GraphClient::new(token, &config.network)?;
Expand Down
Loading
Loading