outlook-cli-rs 0.2.1__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/.github/workflows/ci.yml +11 -0
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/CHANGELOG.md +18 -0
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/Cargo.lock +1 -1
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/Cargo.toml +1 -1
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/PKG-INFO +35 -5
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/README.md +34 -4
- outlook_cli_rs-0.2.2/src/backend.rs +157 -0
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/cli.rs +25 -1
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/desktop.ps1 +93 -3
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/desktop.rs +64 -1
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/lib.rs +1 -0
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/main.rs +57 -122
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/output.rs +26 -3
- outlook_cli_rs-0.2.2/src/presentation.rs +232 -0
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/schema.rs +3 -2
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/tests/cli.rs +26 -0
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/tests/desktop.rs +56 -16
- outlook_cli_rs-0.2.2/tests/desktop_bridge.rs +252 -0
- outlook_cli_rs-0.2.2/tests/fixtures/desktop-mock.ps1 +144 -0
- outlook_cli_rs-0.2.2/tests/presentation.rs +82 -0
- outlook_cli_rs-0.2.1/src/backend.rs +0 -75
- outlook_cli_rs-0.2.1/tests/desktop_bridge.rs +0 -114
- outlook_cli_rs-0.2.1/tests/fixtures/desktop-mock.ps1 +0 -73
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/.github/dependabot.yml +0 -0
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/.gitignore +0 -0
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/LICENSE +0 -0
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/Makefile +0 -0
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/pyproject.toml +0 -0
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/auth.rs +0 -0
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/config.rs +0 -0
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/error.rs +0 -0
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/graph.rs +0 -0
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/tests/fixtures/clispec-v0.3.json +0 -0
- {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/tests/graph.rs +0 -0
|
@@ -22,7 +22,18 @@ jobs:
|
|
|
22
22
|
toolchain: "1.90.0"
|
|
23
23
|
components: rustfmt, clippy
|
|
24
24
|
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
|
|
25
|
+
- name: Install Linux credential store
|
|
26
|
+
if: runner.os == 'Linux'
|
|
27
|
+
run: sudo apt-get update && sudo apt-get install -y gnome-keyring dbus-x11
|
|
28
|
+
- name: Test with Linux credential store
|
|
29
|
+
if: runner.os == 'Linux'
|
|
30
|
+
run: |
|
|
31
|
+
dbus-run-session -- bash -euo pipefail -c '
|
|
32
|
+
printf "\n" | gnome-keyring-daemon --unlock --components=secrets
|
|
33
|
+
cargo test --locked --all-targets
|
|
34
|
+
'
|
|
25
35
|
- run: cargo test --locked --all-targets
|
|
36
|
+
if: runner.os != 'Linux'
|
|
26
37
|
|
|
27
38
|
quality:
|
|
28
39
|
name: Formatting and lints
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2 - 2026-09-08
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Desktop mail send, reply/reply-all, move, delete, and mark-read/unread actions.
|
|
8
|
+
- Desktop draft creation, partial updates, sending, and deletion, including recipient clearing and checks against modifying sent or submitted messages.
|
|
9
|
+
- Improved terminal mail views and command help.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Short output-format flags now control parse-error formatting.
|
|
14
|
+
|
|
15
|
+
### Compatibility
|
|
16
|
+
|
|
17
|
+
- Desktop writes respect read-only profiles and deletion confirmation; moves return the new message ID.
|
|
18
|
+
- Attachments and calendar commands remain Graph-only.
|
|
19
|
+
- Desktop bridge tests pass with mock Outlook objects; live Windows/WSL COM validation remains outstanding.
|
|
20
|
+
|
|
3
21
|
## 0.2.1 - 2026-09-08
|
|
4
22
|
|
|
5
23
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: outlook-cli-rs
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Environment :: Console
|
|
6
6
|
Classifier: Intended Audience :: Developers
|
|
@@ -139,9 +139,19 @@ Well-known folders resolve in the default store. `mail folders` lists its top-le
|
|
|
139
139
|
mail folders; use `--parent` with a returned ID to browse further. Supported names
|
|
140
140
|
are `inbox`, `sentitems`, `deleteditems`, `outbox`, `drafts`, and `junkemail`.
|
|
141
141
|
|
|
142
|
-
Desktop
|
|
143
|
-
|
|
144
|
-
`whoami` return `unsupported
|
|
142
|
+
Desktop supports folder and message listing, reading, search, send, reply/reply-all,
|
|
143
|
+
move, delete, mark-read/unread, and draft list/create/update/send/delete. Attachments,
|
|
144
|
+
calendar commands, and `whoami` return `unsupported`. Read-only profiles block writes;
|
|
145
|
+
deleting messages or drafts requires confirmation (or `--yes`).
|
|
146
|
+
|
|
147
|
+
New messages and replies use plain text and Outlook's configured sending account.
|
|
148
|
+
A successful send means Outlook accepted the message for sending; offline Outlook
|
|
149
|
+
may queue it. Draft updates preserve omitted fields; `--clear-to`, `--clear-cc`,
|
|
150
|
+
and `--clear-bcc` remove the corresponding recipients. Draft update/send/delete
|
|
151
|
+
reject sent messages and messages already submitted for sending. Moves return the
|
|
152
|
+
message's new desktop ID; use that ID for subsequent commands. Delete follows
|
|
153
|
+
Outlook's behavior, including permanent deletion from Deleted Items. If a write
|
|
154
|
+
times out or the bridge fails, check Outlook before retrying: it may have completed.
|
|
145
155
|
There is no automatic fallback to Graph. Existing profiles without a backend field
|
|
146
156
|
continue to use Graph, which remains the default for `init`.
|
|
147
157
|
|
|
@@ -150,7 +160,8 @@ sender address, limited to one folder (inbox by default). KQL and regular expres
|
|
|
150
160
|
are not interpreted. Sender addresses may be Exchange legacy addresses as exposed
|
|
151
161
|
by Outlook. Results reflect the desktop client's available/synchronized data.
|
|
152
162
|
|
|
153
|
-
|
|
163
|
+
Text output includes copyable message IDs and continuation tokens; JSON exposes
|
|
164
|
+
the same values as structured fields. Desktop IDs encode
|
|
154
165
|
both EntryID and StoreID, are unrelated to Graph IDs, and can change after moves.
|
|
155
166
|
Pages scan at most 1,000 items or about 30 seconds before returning a continuation;
|
|
156
167
|
a search page can be empty with more items still to scan. Pass `--cursor` with the
|
|
@@ -204,6 +215,25 @@ never overwrite unless `--force` is supplied; uploads switch automatically to
|
|
|
204
215
|
Microsoft's resumable upload sessions at 3 MiB. Stdout contains data;
|
|
205
216
|
diagnostics and sign-in instructions go to stderr.
|
|
206
217
|
|
|
218
|
+
## Terminal experience
|
|
219
|
+
|
|
220
|
+
Run `outlook` in a terminal for a quick-start guide. Mail lists show full subjects,
|
|
221
|
+
sender details, unread and attachment indicators, and copyable IDs. Message reading
|
|
222
|
+
includes recipients and preserves body paragraphs. Folders show total and unread
|
|
223
|
+
counts; attachments show readable sizes; agendas include timezone and location
|
|
224
|
+
when supplied by the backend. Paginated text output includes the next cursor,
|
|
225
|
+
including when a desktop search returns an empty page with more items to scan.
|
|
226
|
+
|
|
227
|
+
Headings use a restrained cyan accent on terminals. Set `NO_COLOR=1` or pass
|
|
228
|
+
`--no-color` to disable colors. Redirected text has no color escapes. Mail views
|
|
229
|
+
remove terminal control sequences from remote content before displaying it.
|
|
230
|
+
|
|
231
|
+
```console
|
|
232
|
+
outlook inbox --output text # Readable output even when redirected
|
|
233
|
+
outlook inbox --output json # Structured records for scripts
|
|
234
|
+
outlook --no-color mail read MESSAGE_ID
|
|
235
|
+
```
|
|
236
|
+
|
|
207
237
|
## Status
|
|
208
238
|
|
|
209
239
|
The command surface is under active development. A keyboard-first TUI, rich
|
|
@@ -115,9 +115,19 @@ Well-known folders resolve in the default store. `mail folders` lists its top-le
|
|
|
115
115
|
mail folders; use `--parent` with a returned ID to browse further. Supported names
|
|
116
116
|
are `inbox`, `sentitems`, `deleteditems`, `outbox`, `drafts`, and `junkemail`.
|
|
117
117
|
|
|
118
|
-
Desktop
|
|
119
|
-
|
|
120
|
-
`whoami` return `unsupported
|
|
118
|
+
Desktop supports folder and message listing, reading, search, send, reply/reply-all,
|
|
119
|
+
move, delete, mark-read/unread, and draft list/create/update/send/delete. Attachments,
|
|
120
|
+
calendar commands, and `whoami` return `unsupported`. Read-only profiles block writes;
|
|
121
|
+
deleting messages or drafts requires confirmation (or `--yes`).
|
|
122
|
+
|
|
123
|
+
New messages and replies use plain text and Outlook's configured sending account.
|
|
124
|
+
A successful send means Outlook accepted the message for sending; offline Outlook
|
|
125
|
+
may queue it. Draft updates preserve omitted fields; `--clear-to`, `--clear-cc`,
|
|
126
|
+
and `--clear-bcc` remove the corresponding recipients. Draft update/send/delete
|
|
127
|
+
reject sent messages and messages already submitted for sending. Moves return the
|
|
128
|
+
message's new desktop ID; use that ID for subsequent commands. Delete follows
|
|
129
|
+
Outlook's behavior, including permanent deletion from Deleted Items. If a write
|
|
130
|
+
times out or the bridge fails, check Outlook before retrying: it may have completed.
|
|
121
131
|
There is no automatic fallback to Graph. Existing profiles without a backend field
|
|
122
132
|
continue to use Graph, which remains the default for `init`.
|
|
123
133
|
|
|
@@ -126,7 +136,8 @@ sender address, limited to one folder (inbox by default). KQL and regular expres
|
|
|
126
136
|
are not interpreted. Sender addresses may be Exchange legacy addresses as exposed
|
|
127
137
|
by Outlook. Results reflect the desktop client's available/synchronized data.
|
|
128
138
|
|
|
129
|
-
|
|
139
|
+
Text output includes copyable message IDs and continuation tokens; JSON exposes
|
|
140
|
+
the same values as structured fields. Desktop IDs encode
|
|
130
141
|
both EntryID and StoreID, are unrelated to Graph IDs, and can change after moves.
|
|
131
142
|
Pages scan at most 1,000 items or about 30 seconds before returning a continuation;
|
|
132
143
|
a search page can be empty with more items still to scan. Pass `--cursor` with the
|
|
@@ -180,6 +191,25 @@ never overwrite unless `--force` is supplied; uploads switch automatically to
|
|
|
180
191
|
Microsoft's resumable upload sessions at 3 MiB. Stdout contains data;
|
|
181
192
|
diagnostics and sign-in instructions go to stderr.
|
|
182
193
|
|
|
194
|
+
## Terminal experience
|
|
195
|
+
|
|
196
|
+
Run `outlook` in a terminal for a quick-start guide. Mail lists show full subjects,
|
|
197
|
+
sender details, unread and attachment indicators, and copyable IDs. Message reading
|
|
198
|
+
includes recipients and preserves body paragraphs. Folders show total and unread
|
|
199
|
+
counts; attachments show readable sizes; agendas include timezone and location
|
|
200
|
+
when supplied by the backend. Paginated text output includes the next cursor,
|
|
201
|
+
including when a desktop search returns an empty page with more items to scan.
|
|
202
|
+
|
|
203
|
+
Headings use a restrained cyan accent on terminals. Set `NO_COLOR=1` or pass
|
|
204
|
+
`--no-color` to disable colors. Redirected text has no color escapes. Mail views
|
|
205
|
+
remove terminal control sequences from remote content before displaying it.
|
|
206
|
+
|
|
207
|
+
```console
|
|
208
|
+
outlook inbox --output text # Readable output even when redirected
|
|
209
|
+
outlook inbox --output json # Structured records for scripts
|
|
210
|
+
outlook --no-color mail read MESSAGE_ID
|
|
211
|
+
```
|
|
212
|
+
|
|
183
213
|
## Status
|
|
184
214
|
|
|
185
215
|
The command surface is under active development. A keyboard-first TUI, rich
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
//! Shared mail operations. Graph-only operations remain on GraphClient.
|
|
2
|
+
use crate::{
|
|
3
|
+
auth,
|
|
4
|
+
config::{self, BackendKind},
|
|
5
|
+
desktop::DesktopClient,
|
|
6
|
+
error::AppError,
|
|
7
|
+
graph::{GraphClient, Page},
|
|
8
|
+
};
|
|
9
|
+
use serde_json::Value;
|
|
10
|
+
|
|
11
|
+
pub enum MailBackend {
|
|
12
|
+
Graph(GraphClient),
|
|
13
|
+
Desktop(DesktopClient),
|
|
14
|
+
}
|
|
15
|
+
impl MailBackend {
|
|
16
|
+
pub async fn connect_writable(profile: Option<&str>) -> Result<Self, AppError> {
|
|
17
|
+
let (_, selected) = config::load(profile)?;
|
|
18
|
+
selected.require_writable()?;
|
|
19
|
+
Self::connect(profile).await
|
|
20
|
+
}
|
|
21
|
+
pub async fn delete_draft(&self, id: &str) -> Result<Value, AppError> {
|
|
22
|
+
match self {
|
|
23
|
+
Self::Graph(client) => client.delete_message(id).await,
|
|
24
|
+
Self::Desktop(client) => client.delete_draft(id).await,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
pub async fn send_mail(
|
|
28
|
+
&self,
|
|
29
|
+
to: &[String],
|
|
30
|
+
cc: &[String],
|
|
31
|
+
bcc: &[String],
|
|
32
|
+
subject: &str,
|
|
33
|
+
body: &str,
|
|
34
|
+
) -> Result<Value, AppError> {
|
|
35
|
+
match self {
|
|
36
|
+
Self::Graph(client) => client.send_mail(to, cc, bcc, subject, body).await,
|
|
37
|
+
Self::Desktop(client) => client.send_mail(to, cc, bcc, subject, body).await,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
pub async fn create_draft(
|
|
41
|
+
&self,
|
|
42
|
+
to: &[String],
|
|
43
|
+
cc: &[String],
|
|
44
|
+
bcc: &[String],
|
|
45
|
+
subject: &str,
|
|
46
|
+
body: &str,
|
|
47
|
+
) -> Result<Value, AppError> {
|
|
48
|
+
match self {
|
|
49
|
+
Self::Graph(client) => client.create_draft(to, cc, bcc, subject, body).await,
|
|
50
|
+
Self::Desktop(client) => client.create_draft(to, cc, bcc, subject, body).await,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
pub async fn update_draft(
|
|
54
|
+
&self,
|
|
55
|
+
id: &str,
|
|
56
|
+
to: Option<&[String]>,
|
|
57
|
+
cc: Option<&[String]>,
|
|
58
|
+
bcc: Option<&[String]>,
|
|
59
|
+
subject: Option<&str>,
|
|
60
|
+
body: Option<&str>,
|
|
61
|
+
) -> Result<Value, AppError> {
|
|
62
|
+
match self {
|
|
63
|
+
Self::Graph(client) => client.update_draft(id, to, cc, bcc, subject, body).await,
|
|
64
|
+
Self::Desktop(client) => client.update_draft(id, to, cc, bcc, subject, body).await,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
pub async fn send_draft(&self, id: &str) -> Result<Value, AppError> {
|
|
68
|
+
match self {
|
|
69
|
+
Self::Graph(client) => client.send_draft(id).await,
|
|
70
|
+
Self::Desktop(client) => client.send_draft(id).await,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
pub async fn reply(&self, id: &str, body: &str, all: bool) -> Result<Value, AppError> {
|
|
74
|
+
match self {
|
|
75
|
+
Self::Graph(client) => client.reply(id, body, all).await,
|
|
76
|
+
Self::Desktop(client) => client.reply(id, body, all).await,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
pub async fn move_message(&self, id: &str, destination: &str) -> Result<Value, AppError> {
|
|
80
|
+
match self {
|
|
81
|
+
Self::Graph(client) => client.move_message(id, destination).await,
|
|
82
|
+
Self::Desktop(client) => client.move_message(id, destination).await,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
pub async fn set_message_read(&self, id: &str, read: bool) -> Result<Value, AppError> {
|
|
86
|
+
match self {
|
|
87
|
+
Self::Graph(client) => client.set_message_read(id, read).await,
|
|
88
|
+
Self::Desktop(client) => client.set_message_read(id, read).await,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
pub async fn delete_message(&self, id: &str) -> Result<Value, AppError> {
|
|
92
|
+
match self {
|
|
93
|
+
Self::Graph(client) => client.delete_message(id).await,
|
|
94
|
+
Self::Desktop(client) => client.delete_message(id).await,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
pub async fn connect(profile: Option<&str>) -> Result<Self, AppError> {
|
|
99
|
+
let (name, profile) = config::load(profile)?;
|
|
100
|
+
match profile.backend {
|
|
101
|
+
BackendKind::Graph => Ok(Self::Graph(GraphClient::new(
|
|
102
|
+
auth::access_token(&name, &profile).await?,
|
|
103
|
+
))),
|
|
104
|
+
BackendKind::Desktop => Ok(Self::Desktop(DesktopClient)),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
pub async fn messages(
|
|
108
|
+
&self,
|
|
109
|
+
folder: &str,
|
|
110
|
+
limit: u16,
|
|
111
|
+
cursor: Option<&str>,
|
|
112
|
+
) -> Result<Page, AppError> {
|
|
113
|
+
match self {
|
|
114
|
+
Self::Graph(client) => client.messages(folder, limit, cursor).await,
|
|
115
|
+
Self::Desktop(client) => client.page("list", Some(folder), None, limit, cursor).await,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
pub async fn message(&self, id: &str) -> Result<Value, AppError> {
|
|
119
|
+
match self {
|
|
120
|
+
Self::Graph(client) => client.message(id).await,
|
|
121
|
+
Self::Desktop(client) => client.message(id).await,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
pub async fn search_messages(
|
|
125
|
+
&self,
|
|
126
|
+
query: &str,
|
|
127
|
+
folder: Option<&str>,
|
|
128
|
+
limit: u16,
|
|
129
|
+
cursor: Option<&str>,
|
|
130
|
+
) -> Result<Page, AppError> {
|
|
131
|
+
match self {
|
|
132
|
+
Self::Graph(client) => client.search_messages(query, folder, limit, cursor).await,
|
|
133
|
+
Self::Desktop(client) => {
|
|
134
|
+
client
|
|
135
|
+
.page(
|
|
136
|
+
"search",
|
|
137
|
+
Some(folder.unwrap_or("inbox")),
|
|
138
|
+
Some(query),
|
|
139
|
+
limit,
|
|
140
|
+
cursor,
|
|
141
|
+
)
|
|
142
|
+
.await
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
pub async fn folders(
|
|
147
|
+
&self,
|
|
148
|
+
parent: Option<&str>,
|
|
149
|
+
limit: u16,
|
|
150
|
+
cursor: Option<&str>,
|
|
151
|
+
) -> Result<Page, AppError> {
|
|
152
|
+
match self {
|
|
153
|
+
Self::Graph(client) => client.folders(parent, limit, cursor).await,
|
|
154
|
+
Self::Desktop(client) => client.page("folders", parent, None, limit, cursor).await,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -10,15 +10,22 @@ use crate::output::OutputFormat;
|
|
|
10
10
|
name = "outlook",
|
|
11
11
|
version,
|
|
12
12
|
about = "Microsoft Outlook from your terminal, for humans and agents",
|
|
13
|
-
|
|
13
|
+
styles = help_styles(),
|
|
14
|
+
after_help = "Get started:\n outlook init Configure and sign in\n outlook inbox Read recent mail\n outlook mail search 'quarterly report'\n outlook doctor Check your connection\n\nAutomation:\n outlook inbox --output json\n outlook schema --command 'mail send'\n\nRun outlook <command> --help for details and examples."
|
|
14
15
|
)]
|
|
15
16
|
pub struct Cli {
|
|
17
|
+
/// Use a named profile instead of the active profile
|
|
16
18
|
#[arg(long, global = true, env = "OUTLOOK_PROFILE")]
|
|
17
19
|
pub profile: Option<String>,
|
|
20
|
+
/// Output format (auto: text in a terminal, JSON when piped)
|
|
18
21
|
#[arg(short = 'o', long, global = true, value_enum, default_value = "auto")]
|
|
19
22
|
pub output: OutputFormat,
|
|
23
|
+
/// Suppress informational messages on stderr
|
|
20
24
|
#[arg(long, global = true)]
|
|
21
25
|
pub quiet: bool,
|
|
26
|
+
/// Disable terminal colors (also respects NO_COLOR)
|
|
27
|
+
#[arg(long, global = true)]
|
|
28
|
+
pub no_color: bool,
|
|
22
29
|
/// Skip confirmation prompts for destructive operations
|
|
23
30
|
#[arg(long, short = 'y', global = true)]
|
|
24
31
|
pub yes: bool,
|
|
@@ -122,7 +129,9 @@ pub enum ProfileCommand {
|
|
|
122
129
|
|
|
123
130
|
#[derive(Debug, Subcommand)]
|
|
124
131
|
pub enum ConfigCommand {
|
|
132
|
+
/// Show the resolved configuration without secrets
|
|
125
133
|
Show,
|
|
134
|
+
/// Print the configuration file location
|
|
126
135
|
Path,
|
|
127
136
|
}
|
|
128
137
|
|
|
@@ -140,6 +149,9 @@ pub struct PageArgs {
|
|
|
140
149
|
}
|
|
141
150
|
|
|
142
151
|
#[derive(Debug, Subcommand)]
|
|
152
|
+
#[command(
|
|
153
|
+
after_help = "Examples:\n outlook mail list --folder sentitems\n outlook mail read MESSAGE_ID\n outlook mail search 'quarterly report'\n outlook mail send --to person@example.com --subject 'Hello' --body 'Hi'\n\nMessage IDs appear in list output. Use --body - to read a body from stdin."
|
|
154
|
+
)]
|
|
143
155
|
pub enum MailCommand {
|
|
144
156
|
/// List mail folders (use --parent to list child folders)
|
|
145
157
|
Folders {
|
|
@@ -292,6 +304,9 @@ pub enum DraftCommand {
|
|
|
292
304
|
}
|
|
293
305
|
|
|
294
306
|
#[derive(Debug, Subcommand)]
|
|
307
|
+
#[command(
|
|
308
|
+
after_help = "Examples:\n outlook calendar agenda --start 2026-09-08T00:00:00Z --end 2026-09-15T00:00:00Z\n outlook calendar create --subject 'Project sync' --start 2026-09-09T09:00:00 --end 2026-09-09T09:30:00 --timezone Europe/Amsterdam\n\nCalendar commands require a Graph profile."
|
|
309
|
+
)]
|
|
295
310
|
pub enum CalendarCommand {
|
|
296
311
|
/// List occurrences and events in a time range
|
|
297
312
|
Agenda {
|
|
@@ -323,3 +338,12 @@ pub enum CalendarCommand {
|
|
|
323
338
|
body: String,
|
|
324
339
|
},
|
|
325
340
|
}
|
|
341
|
+
|
|
342
|
+
fn help_styles() -> clap::builder::Styles {
|
|
343
|
+
use clap::builder::styling::AnsiColor;
|
|
344
|
+
clap::builder::Styles::styled()
|
|
345
|
+
.header(AnsiColor::Cyan.on_default().bold())
|
|
346
|
+
.usage(AnsiColor::Cyan.on_default().bold())
|
|
347
|
+
.literal(AnsiColor::Green.on_default())
|
|
348
|
+
.placeholder(AnsiColor::Yellow.on_default())
|
|
349
|
+
}
|
|
@@ -45,6 +45,44 @@ function Message($mail, $storeId, $detail) {
|
|
|
45
45
|
if ($detail) { $result.body = @{contentType='text'; content=[string]$mail.Body} }
|
|
46
46
|
return $result
|
|
47
47
|
}
|
|
48
|
+
function Mail($id) {
|
|
49
|
+
$mail = Keep ($script:ns.GetItemFromID([string]$id.entry, [string]$id.store))
|
|
50
|
+
if ($mail.Class -ne 43) { Invalid 'The requested item is not an email message.' }
|
|
51
|
+
return ,$mail
|
|
52
|
+
}
|
|
53
|
+
function Invalid($message) { throw [ArgumentException]::new($message) }
|
|
54
|
+
function RequireDraft($mail) {
|
|
55
|
+
if ($mail.Sent -or $mail.Submitted) { Invalid 'The requested message is not an editable draft.' }
|
|
56
|
+
}
|
|
57
|
+
function EditMail($mail, $request) {
|
|
58
|
+
$recipients = Keep ($mail.Recipients)
|
|
59
|
+
$type = 0
|
|
60
|
+
foreach ($field in @('to','cc','bcc')) {
|
|
61
|
+
$type++
|
|
62
|
+
if ($null -eq $request.$field) { continue }
|
|
63
|
+
for ($i = $recipients.Count; $i -ge 1; $i--) {
|
|
64
|
+
$recipient = Keep ($recipients.Item($i))
|
|
65
|
+
if ($recipient.Type -eq $type) { $recipients.Remove($i) }
|
|
66
|
+
}
|
|
67
|
+
foreach ($address in $request.$field) {
|
|
68
|
+
$recipient = Keep ($recipients.Add([string]$address))
|
|
69
|
+
$recipient.Type = $type
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if ($null -ne $request.subject) { $mail.Subject = [string]$request.subject }
|
|
73
|
+
if ($null -ne $request.body) { $mail.BodyFormat = 1; $mail.Body = [string]$request.body }
|
|
74
|
+
}
|
|
75
|
+
function SendMail($mail) {
|
|
76
|
+
$recipients = Keep ($mail.Recipients)
|
|
77
|
+
if ($recipients.Count -eq 0 -or -not $recipients.ResolveAll()) {
|
|
78
|
+
Invalid 'The message needs recipients that Outlook can resolve.'
|
|
79
|
+
}
|
|
80
|
+
$mail.Send()
|
|
81
|
+
}
|
|
82
|
+
function SavedMessage($mail) {
|
|
83
|
+
$parent = Keep ($mail.Parent)
|
|
84
|
+
return (Message $mail $parent.StoreID $true)
|
|
85
|
+
}
|
|
48
86
|
try {
|
|
49
87
|
$request = [Console]::In.ReadToEnd() | ConvertFrom-Json
|
|
50
88
|
$app = Keep (New-Object -ComObject Outlook.Application)
|
|
@@ -56,10 +94,59 @@ try {
|
|
|
56
94
|
$result = @{available=$true; outlookProfile=[string]$script:ns.CurrentProfileName; defaultStore=[string]$store.DisplayName; rootFolderId=(Identifier $root.EntryID $root.StoreID)}
|
|
57
95
|
}
|
|
58
96
|
'read' {
|
|
59
|
-
$mail =
|
|
60
|
-
if ($mail.Class -ne 43) { throw 'The requested item is not an email message.' }
|
|
97
|
+
$mail = Mail $request.id
|
|
61
98
|
$result = Message $mail $request.id.store $true
|
|
62
99
|
}
|
|
100
|
+
{ $_ -in 'send','draft_create' } {
|
|
101
|
+
$mail = Keep ($app.CreateItem(0))
|
|
102
|
+
EditMail $mail $request
|
|
103
|
+
if ($request.operation -eq 'send') {
|
|
104
|
+
SendMail $mail
|
|
105
|
+
$result = @{sent=$true; to=@($request.to); cc=@($request.cc); bcc=@($request.bcc); subject=[string]$request.subject}
|
|
106
|
+
} else {
|
|
107
|
+
$mail.Save()
|
|
108
|
+
$result = SavedMessage $mail
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
{ $_ -in 'mark_read','move','delete','reply','draft_update','draft_send','draft_delete' } {
|
|
112
|
+
$mail = Mail $request.id
|
|
113
|
+
$id = Identifier $request.id.entry $request.id.store
|
|
114
|
+
if ($request.operation -like 'draft_*') { RequireDraft $mail }
|
|
115
|
+
switch ($request.operation) {
|
|
116
|
+
'mark_read' {
|
|
117
|
+
$mail.UnRead = -not [bool]$request.read
|
|
118
|
+
$mail.Save()
|
|
119
|
+
$result = SavedMessage $mail
|
|
120
|
+
}
|
|
121
|
+
'move' {
|
|
122
|
+
$destination = Folder $request.folder
|
|
123
|
+
if ($destination.DefaultItemType -ne 0) { Invalid 'The destination is not a mail folder.' }
|
|
124
|
+
$moved = Keep ($mail.Move($destination))
|
|
125
|
+
$result = SavedMessage $moved
|
|
126
|
+
}
|
|
127
|
+
{ $_ -in 'delete','draft_delete' } {
|
|
128
|
+
$mail.Delete()
|
|
129
|
+
$result = @{deleted=$true; message_id=$id}
|
|
130
|
+
}
|
|
131
|
+
'reply' {
|
|
132
|
+
if ($request.all) { $reply = Keep ($mail.ReplyAll()) }
|
|
133
|
+
else { $reply = Keep ($mail.Reply()) }
|
|
134
|
+
$reply.BodyFormat = 1
|
|
135
|
+
$reply.Body = [string]$request.body + "`r`n`r`n" + [string]$reply.Body
|
|
136
|
+
SendMail $reply
|
|
137
|
+
$result = @{sent=$true; message_id=$id; reply_all=[bool]$request.all}
|
|
138
|
+
}
|
|
139
|
+
'draft_update' {
|
|
140
|
+
EditMail $mail $request
|
|
141
|
+
$mail.Save()
|
|
142
|
+
$result = SavedMessage $mail
|
|
143
|
+
}
|
|
144
|
+
'draft_send' {
|
|
145
|
+
SendMail $mail
|
|
146
|
+
$result = @{sent=$true; draft_id=$id}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
63
150
|
{ $_ -in 'list','search','folders' } {
|
|
64
151
|
$folder = Folder $request.folder
|
|
65
152
|
$rows = New-Object 'System.Collections.Generic.List[object]'
|
|
@@ -108,10 +195,13 @@ try {
|
|
|
108
195
|
$kind = 'desktop_error'
|
|
109
196
|
$exception = $_.Exception
|
|
110
197
|
while ($null -ne $exception) {
|
|
198
|
+
if ($exception -is [ArgumentException]) { $kind = 'invalid_input' }
|
|
111
199
|
if ($exception.HResult -eq -2147221233) { $kind = 'not_found' }
|
|
112
200
|
$exception = $exception.InnerException
|
|
113
201
|
}
|
|
114
|
-
$
|
|
202
|
+
$message = $_.Exception.Message
|
|
203
|
+
if ($kind -eq 'desktop_error') { $message += ' Check classic Outlook and its Windows profile for security prompts. A write may have completed; inspect Outlook before retrying.' }
|
|
204
|
+
$failure = @{error=@{kind=$kind; message=$message}}
|
|
115
205
|
[Console]::Out.WriteLine(($failure | ConvertTo-Json -Depth 4 -Compress))
|
|
116
206
|
exit 1
|
|
117
207
|
} finally {
|
|
@@ -98,6 +98,61 @@ fn offset(cursor: Option<&str>, expected: &Cursor) -> Result<u32, AppError> {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
impl DesktopClient {
|
|
101
|
+
pub async fn send_mail(
|
|
102
|
+
&self,
|
|
103
|
+
to: &[String],
|
|
104
|
+
cc: &[String],
|
|
105
|
+
bcc: &[String],
|
|
106
|
+
subject: &str,
|
|
107
|
+
body: &str,
|
|
108
|
+
) -> Result<Value, AppError> {
|
|
109
|
+
self.run(json!({"operation":"send", "to":to, "cc":cc, "bcc":bcc, "subject":subject, "body":body})).await
|
|
110
|
+
}
|
|
111
|
+
pub async fn create_draft(
|
|
112
|
+
&self,
|
|
113
|
+
to: &[String],
|
|
114
|
+
cc: &[String],
|
|
115
|
+
bcc: &[String],
|
|
116
|
+
subject: &str,
|
|
117
|
+
body: &str,
|
|
118
|
+
) -> Result<Value, AppError> {
|
|
119
|
+
self.run(json!({"operation":"draft_create", "to":to, "cc":cc, "bcc":bcc, "subject":subject, "body":body})).await
|
|
120
|
+
}
|
|
121
|
+
pub async fn update_draft(
|
|
122
|
+
&self,
|
|
123
|
+
id: &str,
|
|
124
|
+
to: Option<&[String]>,
|
|
125
|
+
cc: Option<&[String]>,
|
|
126
|
+
bcc: Option<&[String]>,
|
|
127
|
+
subject: Option<&str>,
|
|
128
|
+
body: Option<&str>,
|
|
129
|
+
) -> Result<Value, AppError> {
|
|
130
|
+
self.run(json!({"operation":"draft_update", "id":decode_id(id)?, "to":to, "cc":cc, "bcc":bcc, "subject":subject, "body":body})).await
|
|
131
|
+
}
|
|
132
|
+
pub async fn send_draft(&self, id: &str) -> Result<Value, AppError> {
|
|
133
|
+
self.run(json!({"operation":"draft_send", "id":decode_id(id)?}))
|
|
134
|
+
.await
|
|
135
|
+
}
|
|
136
|
+
pub async fn reply(&self, id: &str, body: &str, all: bool) -> Result<Value, AppError> {
|
|
137
|
+
self.run(json!({"operation":"reply", "id":decode_id(id)?, "body":body, "all":all}))
|
|
138
|
+
.await
|
|
139
|
+
}
|
|
140
|
+
pub async fn move_message(&self, id: &str, destination: &str) -> Result<Value, AppError> {
|
|
141
|
+
self.run(json!({"operation":"move", "id":decode_id(id)?, "folder":folder_value(Some(destination))?})).await
|
|
142
|
+
}
|
|
143
|
+
pub async fn set_message_read(&self, id: &str, read: bool) -> Result<Value, AppError> {
|
|
144
|
+
self.run(json!({"operation":"mark_read", "id":decode_id(id)?, "read":read}))
|
|
145
|
+
.await
|
|
146
|
+
}
|
|
147
|
+
pub async fn delete_message(&self, id: &str) -> Result<Value, AppError> {
|
|
148
|
+
self.run(json!({"operation":"delete", "id":decode_id(id)?}))
|
|
149
|
+
.await
|
|
150
|
+
}
|
|
151
|
+
pub async fn delete_draft(&self, id: &str) -> Result<Value, AppError> {
|
|
152
|
+
self.run(json!({"operation":"draft_delete", "id":decode_id(id)?}))
|
|
153
|
+
.await
|
|
154
|
+
}
|
|
155
|
+
|
|
101
156
|
pub async fn probe(&self) -> Result<Value, AppError> {
|
|
102
157
|
self.run(json!({"operation":"probe"})).await
|
|
103
158
|
}
|
|
@@ -209,7 +264,7 @@ async fn run_bridge(
|
|
|
209
264
|
.map_err(|e| AppError::Desktop(format!("cannot wait for bridge: {e}")))
|
|
210
265
|
};
|
|
211
266
|
let output = tokio::time::timeout(timeout, operation).await
|
|
212
|
-
.map_err(|_| AppError::Desktop("classic Outlook did not respond within the bridge timeout; check Windows for Outlook profile or security prompts".into()))??;
|
|
267
|
+
.map_err(|_| AppError::Desktop("classic Outlook did not respond within the bridge timeout; check Windows for Outlook profile or security prompts. A write may already have completed; inspect Outlook before retrying".into()))??;
|
|
213
268
|
parse_output(output.status.success(), &output.stdout, &output.stderr)
|
|
214
269
|
}
|
|
215
270
|
|
|
@@ -229,6 +284,8 @@ fn parse_output(success: bool, stdout: &[u8], stderr: &[u8]) -> Result<Value, Ap
|
|
|
229
284
|
.to_owned();
|
|
230
285
|
return Err(if error["kind"] == "not_found" {
|
|
231
286
|
AppError::NotFound(message)
|
|
287
|
+
} else if error["kind"] == "invalid_input" {
|
|
288
|
+
AppError::InvalidInput(message)
|
|
232
289
|
} else {
|
|
233
290
|
AppError::Desktop(message)
|
|
234
291
|
});
|
|
@@ -246,6 +303,12 @@ fn parse_output(success: bool, stdout: &[u8], stderr: &[u8]) -> Result<Value, Ap
|
|
|
246
303
|
mod tests {
|
|
247
304
|
use super::*;
|
|
248
305
|
|
|
306
|
+
#[test]
|
|
307
|
+
fn bundled_script_fits_windows_command_line() {
|
|
308
|
+
let encoded_len = SCRIPT.encode_utf16().count().saturating_mul(2).div_ceil(3) * 4;
|
|
309
|
+
assert!(encoded_len < 32000, "leave room for executable and flags");
|
|
310
|
+
}
|
|
311
|
+
|
|
249
312
|
#[test]
|
|
250
313
|
fn ids_require_both_hex_entry_and_store_ids() {
|
|
251
314
|
let id = format!(
|