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.
Files changed (34) hide show
  1. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/.github/workflows/ci.yml +11 -0
  2. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/CHANGELOG.md +18 -0
  3. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/Cargo.lock +1 -1
  4. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/Cargo.toml +1 -1
  5. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/PKG-INFO +35 -5
  6. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/README.md +34 -4
  7. outlook_cli_rs-0.2.2/src/backend.rs +157 -0
  8. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/cli.rs +25 -1
  9. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/desktop.ps1 +93 -3
  10. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/desktop.rs +64 -1
  11. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/lib.rs +1 -0
  12. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/main.rs +57 -122
  13. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/output.rs +26 -3
  14. outlook_cli_rs-0.2.2/src/presentation.rs +232 -0
  15. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/schema.rs +3 -2
  16. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/tests/cli.rs +26 -0
  17. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/tests/desktop.rs +56 -16
  18. outlook_cli_rs-0.2.2/tests/desktop_bridge.rs +252 -0
  19. outlook_cli_rs-0.2.2/tests/fixtures/desktop-mock.ps1 +144 -0
  20. outlook_cli_rs-0.2.2/tests/presentation.rs +82 -0
  21. outlook_cli_rs-0.2.1/src/backend.rs +0 -75
  22. outlook_cli_rs-0.2.1/tests/desktop_bridge.rs +0 -114
  23. outlook_cli_rs-0.2.1/tests/fixtures/desktop-mock.ps1 +0 -73
  24. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/.github/dependabot.yml +0 -0
  25. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/.gitignore +0 -0
  26. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/LICENSE +0 -0
  27. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/Makefile +0 -0
  28. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/pyproject.toml +0 -0
  29. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/auth.rs +0 -0
  30. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/config.rs +0 -0
  31. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/error.rs +0 -0
  32. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/src/graph.rs +0 -0
  33. {outlook_cli_rs-0.2.1 → outlook_cli_rs-0.2.2}/tests/fixtures/clispec-v0.3.json +0 -0
  34. {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
@@ -1365,7 +1365,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
1365
1365
 
1366
1366
  [[package]]
1367
1367
  name = "outlook-cli"
1368
- version = "0.2.1"
1368
+ version = "0.2.2"
1369
1369
  dependencies = [
1370
1370
  "assert_cmd",
1371
1371
  "base64",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "outlook-cli"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  edition = "2024"
5
5
  rust-version = "1.90"
6
6
  description = "Microsoft Outlook from your terminal, for humans and agents"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: outlook-cli-rs
3
- Version: 0.2.1
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 currently supports folder listing, inbox/folder mail listing, message
143
- reading, search, and draft listing. Writes, attachments, calendar commands, and
144
- `whoami` return `unsupported`; read-only profiles also retain their write guard.
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
- Use JSON output to obtain message IDs and continuation tokens. Desktop IDs encode
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 currently supports folder listing, inbox/folder mail listing, message
119
- reading, search, and draft listing. Writes, attachments, calendar commands, and
120
- `whoami` return `unsupported`; read-only profiles also retain their write guard.
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
- Use JSON output to obtain message IDs and continuation tokens. Desktop IDs encode
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
- after_help = "Get started:\n outlook init\n outlook inbox\n outlook calendar agenda --start START --end END\n outlook doctor\n outlook schema --command 'mail send'"
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 = Keep ($script:ns.GetItemFromID([string]$request.id.entry, [string]$request.id.store))
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
- $failure = @{error=@{kind=$kind; message=($_.Exception.Message + ' Check that classic Outlook is installed and its Windows profile is signed in; new Outlook is unsupported.')}}
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!(
@@ -6,4 +6,5 @@ pub mod desktop;
6
6
  pub mod error;
7
7
  pub mod graph;
8
8
  pub mod output;
9
+ pub mod presentation;
9
10
  pub mod schema;