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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@

## Unreleased

### Added

- `teams message send --subject TEXT` sets the subject line on a channel root message — the bold title Teams renders above the body, the same field the client offers behind "Add a subject". Channel sends only: chat messages have no subject, so `--subject` with `--chat` (or without `--channel`) is rejected as invalid input before anything is sent.

### Fixed

- 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.


## 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 @@ -62,6 +62,7 @@ Single-crate Rust binary. Key modules:
### Output Contract
All commands emit a JSON envelope: `{ "success": bool, "data": ..., "metadata": { "request_id", "timestamp", "duration_ms" } }`.
When stdout is a TTY, defaults to human-readable table format. When piped, defaults to JSON.
Plain list columns include keys from every row, with blank cells for absent values. Human message lists include a Subject column; absent message subjects remain omitted in JSON.

### Exit Codes
0=success, 1=general, 2=invalid input, 3=auth, 4=permission denied, 5=not found, 6=rate limited, 7=network, 8=server error, 10=config error
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ teams message send --chat <chat-id> --body "Hello"
teams message send --team <team-id> --channel <channel-id> --body "<h1>Rich</h1>" --content-type html
teams message send --chat <chat-id> --mention <user-id-or-upn> --body "Please review and send the drafts."
teams message send --team <team-id> --channel <channel-id> --mention <user-id> --mention <user-id> --body "Deploy is going out now."
teams message send --team <team-id> --channel <channel-id> --subject "Release plan" --body "Details inside."
echo "Build passed" | teams message send --team <team-id> --channel <channel-id> --stdin
teams message list --team <team-id> --channel <channel-id>
teams message list --chat <chat-id>
Expand All @@ -403,6 +404,14 @@ preserved), the `<at>` elements are prepended in flag order, and a mention by it
as a body (`--body` optional). Raw `<at>` markup typed into an HTML body is rejected with
exit code 2 before anything is sent.

`message send --subject TEXT` sets the subject line on a channel root message — the bold
title Teams shows above the body, the same field the Teams client offers behind "Add a
subject". Channel messages only: chat messages have no subject, so `--subject` with
`--chat` is rejected as invalid input. `message list` and `message get` return the
subject Graph stores, so a posted subject survives a read-back. Human message lists
include a Subject column; plain lists include subjects even when the first message
is untitled. JSON continues to omit the subject field when it is absent.

```bash
teams message send --chat <chat-id> \
--mention sophie@example.com \
Expand Down
10 changes: 9 additions & 1 deletion docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ teams channel members remove TEAM_ID CHANNEL_ID MEMBER_ID
## Messages

```bash
teams message send (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID) [--body TEXT | --stdin] [--content-type text|html] [--adaptive-card PATH] [--image PATH]... [--attach PATH]... [--mention USER]...
teams message send (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID) [--body TEXT | --stdin] [--content-type text|html] [--adaptive-card PATH] [--image PATH]... [--attach PATH]... [--mention USER]... [--subject TEXT]
teams message list (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID)
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)
Expand Down Expand Up @@ -146,6 +146,14 @@ teams message send --team TEAM_ID --channel CHANNEL_ID \
--mention <object-id-1> --mention <object-id-2> --body "Deploy is going out now."
```

`--subject TEXT` sets the subject line on a channel root message — the bold title Teams renders above the body, the same field the client offers behind "Add a subject". Channel sends only: chat messages have no subject, so `--subject` with `--chat` (or without `--channel`) is rejected with exit code 2 before anything is sent. The stored subject comes back on `message list` and `message get`. Human lists include a Subject column. Plain lists collect columns across all messages, so a titled message keeps its subject even when the first message is untitled; missing values are blank. JSON omits absent subjects.

```bash
# Post a channel message with a subject line
teams message send --team TEAM_ID --channel CHANNEL_ID \
--subject "Release plan" --body "Details inside."
```

`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.

## Chats
Expand Down
16 changes: 15 additions & 1 deletion docs/man/teams.1
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ teams channel members remove TEAM_ID CHANNEL_ID MEMBER_ID
.fi
.SH MESSAGE COMMANDS
.nf
teams message send (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID) [--body TEXT | --stdin] [--content-type text|html] [--adaptive-card PATH] [--image PATH]... [--attach PATH]... [--mention USER]...
teams message send (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID) [--body TEXT | --stdin] [--content-type text|html] [--adaptive-card PATH] [--image PATH]... [--attach PATH]... [--mention USER]... [--subject TEXT]
teams message list (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID)
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)
Expand Down Expand Up @@ -200,6 +200,20 @@ array, so the CLI builds both: a plain-text body is converted to HTML (escaped,
line breaks preserved), the <at> elements are prepended to the body in flag
order, and a mention by itself counts as a body. Raw <at> markup typed into an
HTML body is rejected before anything is sent.
.PP
.B --subject TEXT
sets the subject line on a channel root message, the bold title Teams shows
above the body. Channel sends only: chat messages have no subject, so
.B --subject
with
.B --chat
is rejected as invalid input. The subject Graph stores is returned by
.B message list
and
.B message get.
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.
.SH CHAT COMMANDS
.nf
teams chat list
Expand Down
2 changes: 2 additions & 0 deletions src/api/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ mod tests {

fn edit_request() -> SendMessageRequest {
SendMessageRequest {
subject: None,
body: ItemBody {
content_type: Some("text".to_string()),
content: Some("corrected text".to_string()),
Expand Down Expand Up @@ -578,6 +579,7 @@ mod tests {
.await;

let req = SendMessageRequest {
subject: None,
body: ItemBody {
content_type: Some("html".into()),
content: Some("<at id=\"0\">Sophie Daniels</at> Please review".into()),
Expand Down
85 changes: 56 additions & 29 deletions src/cli/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ use crate::auth;
use crate::config::ConfigFile;
use crate::error::{Result, TeamsError};
use crate::models::message::{
ChatMessageAttachment, ChatMessageMention, ChatMessageMentioned, ChatMessageUser, ItemBody,
SendMessageRequest,
ChatMessage, ChatMessageAttachment, ChatMessageMention, ChatMessageMentioned, ChatMessageUser,
ItemBody, SendMessageRequest,
};
use crate::models::user::User;
use crate::output::{self, OutputFormat};
Expand Down Expand Up @@ -47,6 +47,9 @@ pub enum MessageCommand {
/// User to @mention (repeatable): an Entra object ID or UPN
#[arg(long, value_name = "USER")]
mention: Vec<String>,
/// Subject line for a channel root message
#[arg(long, requires = "channel", conflicts_with = "chat")]
subject: Option<String>,
},
/// List messages in a channel or chat
List {
Expand Down Expand Up @@ -295,6 +298,7 @@ pub async fn run(
image,
attach,
mention,
subject,
} => {
let start = Instant::now();
auth::require_delegated_token(&client.token, "Sending Teams messages")?;
Expand All @@ -308,6 +312,7 @@ pub async fn run(
let identities = resolve_mentions(&client, &mention).await?;
ensure_no_raw_at_markup(&content_type, &content)?;
let mut req = build_send_request(content, &content_type, adaptive_card.as_deref())?;
req.subject = subject;
apply_mentions(&mut req, &identities)?;

let msg = if let Some(chat_id) = chat {
Expand Down Expand Up @@ -367,33 +372,8 @@ pub async fn run(
};

if format == OutputFormat::Human {
let headers = vec!["ID", "From", "Body Preview", "Date"];
let rows: Vec<Vec<String>> = messages
.iter()
.map(|m| {
let from = m
.from
.as_ref()
.and_then(|f| f.user.as_ref())
.and_then(|u| u.display_name.clone())
.unwrap_or_default();
let body_preview = m
.body
.as_ref()
.and_then(|b| b.content.as_ref())
.map(|c| {
let clean: String = c.chars().take(60).collect();
clean
})
.unwrap_or_default();
vec![
m.id.clone().unwrap_or_default(),
from,
body_preview,
m.created_date_time.clone().unwrap_or_default(),
]
})
.collect();
let headers = vec!["ID", "From", "Subject", "Body Preview", "Date"];
let rows: Vec<Vec<String>> = messages.iter().map(message_list_row).collect();
output::table::print_table(headers, rows);
} else {
output::print_success_list(format, &messages, start);
Expand Down Expand Up @@ -693,6 +673,26 @@ fn require_channel(team: Option<String>, channel: Option<String>) -> Result<(Str
Ok((team_id, channel_id))
}

fn message_list_row(message: &ChatMessage) -> Vec<String> {
vec![
message.id.clone().unwrap_or_default(),
message
.from
.as_ref()
.and_then(|from| from.user.as_ref())
.and_then(|user| user.display_name.clone())
.unwrap_or_default(),
message.subject.clone().unwrap_or_default(),
message
.body
.as_ref()
.and_then(|body| body.content.as_ref())
.map(|content| content.chars().take(60).collect())
.unwrap_or_default(),
message.created_date_time.clone().unwrap_or_default(),
]
}

fn resolve_body(body: Option<String>, stdin: bool) -> Result<String> {
if stdin {
let mut buf = String::new();
Expand Down Expand Up @@ -720,6 +720,7 @@ fn build_send_request(
adaptive_card_path: Option<&str>,
) -> Result<SendMessageRequest> {
let mut req = SendMessageRequest {
subject: None,
body: ItemBody {
content_type: Some(content_type.to_string()),
content: Some(content),
Expand Down Expand Up @@ -931,6 +932,28 @@ fn escape_body_text(text: &str) -> String {
mod tests {
use super::*;

#[test]
fn human_message_row_preserves_subject_and_body() {
let mut message: ChatMessage = serde_json::from_value(serde_json::json!({
"id": "message-id", "subject": "Release α & <plan>",
"from": {"user": {"displayName": "Example User"}},
"body": {"content": "Details"}, "createdDateTime": "2026-01-01T00:00:00Z"
}))
.unwrap();
assert_eq!(
message_list_row(&message),
[
"message-id",
"Example User",
"Release α & <plan>",
"Details",
"2026-01-01T00:00:00Z"
]
);
message.subject = None;
assert_eq!(message_list_row(&message)[2], "");
}

fn write_card(dir: &std::path::Path) -> String {
let path = dir.join("card.json");
std::fs::write(
Expand Down Expand Up @@ -1046,6 +1069,7 @@ mod tests {

fn html_request(body: &str) -> SendMessageRequest {
SendMessageRequest {
subject: None,
body: ItemBody {
content_type: Some("html".into()),
content: Some(body.into()),
Expand Down Expand Up @@ -1145,6 +1169,7 @@ mod tests {
#[test]
fn text_body_is_promoted_to_html_with_line_breaks_intact() {
let mut req = SendMessageRequest {
subject: None,
body: ItemBody {
content_type: Some("text".into()),
content: Some("line one\nline <two> & three".into()),
Expand Down Expand Up @@ -1179,6 +1204,7 @@ mod tests {
#[test]
fn mention_alone_becomes_the_whole_body() {
let mut req = SendMessageRequest {
subject: None,
body: ItemBody {
content_type: Some("text".into()),
content: Some(String::new()),
Expand Down Expand Up @@ -1232,6 +1258,7 @@ mod tests {
#[test]
fn no_mentions_leaves_the_request_untouched() {
let mut text_req = SendMessageRequest {
subject: None,
body: ItemBody {
content_type: Some("text".into()),
content: Some("plain & simple\nbody".into()),
Expand Down
1 change: 1 addition & 0 deletions src/cli/message_media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ mod tests {

fn text_request(body: &str) -> SendMessageRequest {
SendMessageRequest {
subject: None,
body: ItemBody {
content_type: Some("text".into()),
content: Some(body.into()),
Expand Down
1 change: 1 addition & 0 deletions src/models/attachment_inventory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ mod tests {
ChatMessage {
id: Some("1783503421261".into()),
created_date_time: None,
subject: None,
from: None,
body: Some(ItemBody {
content_type: Some("html".into()),
Expand Down
55 changes: 55 additions & 0 deletions src/models/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ pub struct ChatMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub created_date_time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub from: Option<ChatMessageFrom>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<ItemBody>,
Expand Down Expand Up @@ -93,6 +95,8 @@ pub struct ChatMessageMentioned {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SendMessageRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
pub body: ItemBody,
#[serde(skip_serializing_if = "Option::is_none")]
pub attachments: Option<Vec<ChatMessageAttachment>>,
Expand Down Expand Up @@ -184,6 +188,7 @@ mod tests {
let msg = ChatMessage {
id: Some("msg1".into()),
created_date_time: Some("2024-01-01T00:00:00Z".into()),
subject: None,
from: Some(ChatMessageFrom {
user: Some(ChatMessageUser {
id: Some("u1".into()),
Expand All @@ -208,6 +213,7 @@ mod tests {
#[test]
fn send_request_serializes_hosted_contents_with_temporary_id() {
let req = SendMessageRequest {
subject: None,
body: ItemBody {
content_type: Some("html".into()),
content: Some(r#"<p><img src="../hostedContents/1/$value"></p>"#.into()),
Expand All @@ -233,6 +239,7 @@ mod tests {
#[test]
fn send_request_serializes_the_exact_mention_shape() {
let req = SendMessageRequest {
subject: None,
body: ItemBody {
content_type: Some("html".into()),
content: Some(r#"<at id="0">Sophie Daniels</at> Please review"#.into()),
Expand Down Expand Up @@ -322,6 +329,54 @@ mod tests {
);
}

/// Graph returns `subject` on channel root messages; it must survive a
/// parse/print round trip instead of being dropped from JSON output.
#[test]
fn chat_message_keeps_returned_subject() {
let json = serde_json::json!({
"id": "1700000000000",
"subject": "Release plan",
"body": { "contentType": "html", "content": "Team, details inside." }
});
let msg: ChatMessage = serde_json::from_value(json).unwrap();
assert_eq!(msg.subject.as_deref(), Some("Release plan"));

let re = serde_json::to_value(&msg).unwrap();
assert_eq!(re["subject"], "Release plan");
}

/// Messages without a subject (every chat message, most replies) must not
/// gain a `"subject": null` field on output.
#[test]
fn chat_message_without_subject_omits_the_field() {
let json = serde_json::json!({
"id": "1700000000001",
"body": { "contentType": "text", "content": "hi" }
});
let msg: ChatMessage = serde_json::from_value(json).unwrap();
assert!(msg.subject.is_none());

let re = serde_json::to_value(&msg).unwrap();
assert!(re.get("subject").is_none());
}

#[test]
fn send_request_serializes_subject_at_top_level() {
let req = SendMessageRequest {
subject: Some("Release plan".into()),
body: ItemBody {
content_type: Some("text".into()),
content: Some("Team, details inside.".into()),
},
attachments: None,
hosted_contents: None,
mentions: None,
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["subject"], "Release plan");
assert_eq!(json["body"]["content"], "Team, details inside.");
}

#[test]
fn reference_attachment_keeps_content_url() {
let json = r#"{
Expand Down
Loading