@griffinwork40/outlook-cli 0.0.0-stage → 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +324 -2
- package/SETUP.md +199 -0
- package/dist/cli.js +3286 -0
- package/dist/cli.js.map +1 -0
- package/package.json +59 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Griffin Long
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,325 @@
|
|
|
1
|
-
#
|
|
1
|
+
# outlook-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A lightweight CLI wrapper for the **Microsoft Graph v1.0 Outlook Mail API** —
|
|
4
|
+
read, search, send, reply, forward, draft, organize, and handle attachments
|
|
5
|
+
from the shell or from an agent.
|
|
6
|
+
|
|
7
|
+
Built so an AI agent can do everything email-related through a Microsoft
|
|
8
|
+
account without a browser.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pnpm install && pnpm build
|
|
16
|
+
|
|
17
|
+
# one-time: register an Azure app and export its client id (see SETUP.md)
|
|
18
|
+
export GRAPH_CLIENT_ID=<application-client-id>
|
|
19
|
+
|
|
20
|
+
outlook auth login # device-code sign-in, ~30 seconds
|
|
21
|
+
outlook me # confirm which mailbox you are on
|
|
22
|
+
outlook list # newest inbox messages
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**First time?** Read [SETUP.md](./SETUP.md). The Azure app registration is the
|
|
26
|
+
only step that cannot be automated, and it takes about three minutes.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Safety model
|
|
31
|
+
|
|
32
|
+
Email is irreversible and reaches other people. Six deliberate guardrails:
|
|
33
|
+
|
|
34
|
+
**1. `--dry-run` on every mutating command.** Prints the exact HTTP request that
|
|
35
|
+
would be issued, then exits without touching the network. The short-circuit
|
|
36
|
+
lives in one shared chokepoint (`guardMutation`), not in each command, so no
|
|
37
|
+
command can forget it.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
outlook send --to a@example.com --subject "Test" --body "Hi" --dry-run
|
|
41
|
+
```
|
|
42
|
+
```
|
|
43
|
+
DRY RUN — nothing was sent. The request below is what would go out:
|
|
44
|
+
|
|
45
|
+
POST https://graph.microsoft.com/v1.0/me/sendMail
|
|
46
|
+
Content-Type: application/json
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
"message": { "subject": "Test", ... },
|
|
50
|
+
"saveToSentItems": true
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**2. `delete` is recoverable; `purge` is not and is gated.** `outlook delete`
|
|
55
|
+
*moves* the message to Deleted Items, where you can get it back. Microsoft's
|
|
56
|
+
documentation never states whether a raw `DELETE` is recoverable, so that path
|
|
57
|
+
is exposed separately as `outlook purge` and refuses to run without `--yes`.
|
|
58
|
+
|
|
59
|
+
**3. Sends are never retried.** `sendMail`, `reply`, `replyAll`, `forward` and
|
|
60
|
+
draft-send carry no idempotency guarantee — an automatic retry after a network
|
|
61
|
+
timeout can deliver the message twice. The HTTP layer retries idempotent verbs
|
|
62
|
+
(GET/DELETE/PATCH) on throttling, and deliberately does not retry POST.
|
|
63
|
+
|
|
64
|
+
**4. Unknown flags are rejected, not ignored.** A misspelled flag is a hard
|
|
65
|
+
error before anything runs. This matters more than it sounds: with a
|
|
66
|
+
permissive parser, `--dryrun` is silently ignored, `dryRun` stays false, and
|
|
67
|
+
"show me what would be sent" becomes "send it now." Every command declares its
|
|
68
|
+
accepted flags in `src/flagSpec.ts`, and a test asserts no command can be
|
|
69
|
+
registered without an entry.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
outlook send --to a@example.com --subject "Test" --body "Hi" --dryrun
|
|
73
|
+
```
|
|
74
|
+
```
|
|
75
|
+
Unknown flag for 'send': --dryrun
|
|
76
|
+
Did you mean --dry-run?
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**5. Confirmation flags cannot be faked.** `--yes` accepts only explicit
|
|
80
|
+
boolean spellings. `--yes=false`, `--yes=0` and `--no-yes` all mean *no* — a
|
|
81
|
+
user typing `--yes=false` to decline a permanent delete gets a refusal, not a
|
|
82
|
+
deletion.
|
|
83
|
+
|
|
84
|
+
**6. Least privilege is available.** `outlook auth login --readonly` requests
|
|
85
|
+
`Mail.Read` instead of `Mail.ReadWrite` and drops `Mail.Send` entirely, so the
|
|
86
|
+
**access** token this session carries cannot send, delete, or modify mail. The
|
|
87
|
+
mode is recorded with the token and survives refreshes, and any mutating
|
|
88
|
+
command fails fast against a read-only session. Recommended whenever an agent
|
|
89
|
+
only needs to read.
|
|
90
|
+
|
|
91
|
+
It narrows the blast radius; it is not a substitute for protecting the token
|
|
92
|
+
file. The local read-only gate reads `mode` from that same file, and the cached
|
|
93
|
+
refresh token is only as narrow as the consent your tenant has already recorded
|
|
94
|
+
for this client — so someone holding the file can edit the mode back. Treat
|
|
95
|
+
`--readonly` as reducing what a leaked token is worth, not as sealing it.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
outlook auth login --readonly
|
|
99
|
+
outlook send --to a@example.com --subject S --body B
|
|
100
|
+
# This session was signed in with --readonly, so it cannot POST .../sendMail.
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Credentialed requests are additionally pinned to HTTPS on the Graph host, so a
|
|
104
|
+
malformed or hostile `@odata.nextLink` cannot redirect your bearer token to
|
|
105
|
+
another server. See [docs/security-review-2026-08-07.md](./docs/security-review-2026-08-07.md)
|
|
106
|
+
for the full audit and the two items deliberately left open.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Restricted agent mode
|
|
111
|
+
|
|
112
|
+
An unattended agent that only files mail should not be able to send, edit
|
|
113
|
+
rules, or move messages anywhere it likes. Two opt-in controls narrow what the
|
|
114
|
+
CLI will do for it. With neither set, behavior is unchanged.
|
|
115
|
+
|
|
116
|
+
**1. A scoped token in its own cache.** `--scopes` requests exactly the listed
|
|
117
|
+
scopes (`offline_access` is added if missing) and records that set with the
|
|
118
|
+
token; `auth refresh` re-requests the same set rather than the default list, so
|
|
119
|
+
the grant never widens. `GRAPH_TOKEN_CACHE` keeps it apart from your main
|
|
120
|
+
login.
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
export GRAPH_TOKEN_CACHE=~/.afk/state/outlook/agent-token.json
|
|
124
|
+
outlook auth login --scopes Mail.ReadWrite
|
|
125
|
+
outlook auth status --json # "grantScopes": ["offline_access", "Mail.ReadWrite"]
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**2. A mutation policy.** `OUTLOOK_POLICY=<path>` points at a JSON file that
|
|
129
|
+
every mutating request is checked against, inside the same `guardMutation`
|
|
130
|
+
chokepoint that implements `--dry-run`. Reads are never affected.
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{
|
|
134
|
+
"$comment": "mail-filing agent: two destinations, nothing else",
|
|
135
|
+
"allowCommands": ["move", "mark-read"],
|
|
136
|
+
"move": {
|
|
137
|
+
"allowedDestinations": ["AAMkAGI2...ReviewFolderId", "archive"],
|
|
138
|
+
"denySenders": {
|
|
139
|
+
"file": "protected_senders.json",
|
|
140
|
+
"maxAgeHours": 48,
|
|
141
|
+
"destinations": ["AAMkAGI2...ReviewFolderId"]
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
| Key | Effect |
|
|
148
|
+
|---|---|
|
|
149
|
+
| `allowCommands` | Mutating commands that may run. `"draft create"` allows one subcommand; `"draft"` allows them all. Anything else (send, reply, rules create, purge) is refused. |
|
|
150
|
+
| `move.allowedDestinations` | Folder ids or well-known names a move (including `delete`, which moves to `deleteditems`) may target. Well-known names match case-insensitively; ids match exactly, so list the form the agent will pass. |
|
|
151
|
+
| `move.denySenders` | Before a move into one of `destinations`, the CLI fetches the message's `from` (`GET /me/messages/{id}?$select=from`) and refuses if that address is in `file` (relative to the policy file): a JSON array of addresses, or `{"addresses": [...], "built_at": "<ISO time>"}`. |
|
|
152
|
+
|
|
153
|
+
It fails closed. A missing, unparseable or misspelled policy file (unknown keys
|
|
154
|
+
are errors) refuses every mutation. A deny list that is missing, unparseable,
|
|
155
|
+
or older than `maxAgeHours` (by `built_at`, else file mtime) refuses moves to
|
|
156
|
+
its destinations. A message with no sender address is refused for those
|
|
157
|
+
destinations, and so is a message id that would change the request path
|
|
158
|
+
(`../`, `%2e%2e`, backslashes).
|
|
159
|
+
|
|
160
|
+
Refusals exit with code **4** and, under `--json`, print
|
|
161
|
+
`{"error": {"code": "PolicyRefused", "rule": "...", "message": "..."}}` to
|
|
162
|
+
stdout. `--dry-run` still prints the request without any network call and adds
|
|
163
|
+
the policy's verdict; the deny-list sender lookup is a network call, so a dry
|
|
164
|
+
run reports it as skipped.
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
export OUTLOOK_POLICY=~/.afk/config/agent-policy.json
|
|
168
|
+
outlook send --to a@example.com --subject S --body B
|
|
169
|
+
# Refused by mutation policy (allowCommands): 'send' is not in allowCommands (exit 4)
|
|
170
|
+
outlook move <id> --folder inbox --dry-run
|
|
171
|
+
# ...request...
|
|
172
|
+
# Policy (/home/me/.afk/config/agent-policy.json): would REFUSE (move.allowedDestinations): ...
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The policy is a guard inside the CLI, not an operating-system boundary. A
|
|
176
|
+
process that can run its own code as your user can read the token cache and
|
|
177
|
+
call Graph directly, or unset the variable. Pair it with a scoped token (a
|
|
178
|
+
`Mail.ReadWrite`-only token cannot send mail or edit inbox rules even outside
|
|
179
|
+
the CLI), and keep the policy and deny list files out of the agent's write
|
|
180
|
+
reach.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Commands
|
|
185
|
+
|
|
186
|
+
### Auth
|
|
187
|
+
| Command | Purpose |
|
|
188
|
+
|---|---|
|
|
189
|
+
| `outlook auth login` | Device-code sign-in (`--readonly`, or `--scopes a,b` for an exact scope set) |
|
|
190
|
+
| `outlook auth status` | Signed-in identity and token expiry (token never printed) |
|
|
191
|
+
| `outlook auth refresh` | Force a token refresh |
|
|
192
|
+
| `outlook auth logout` | Delete the cached token |
|
|
193
|
+
|
|
194
|
+
A plain `outlook auth login` requests `offline_access`, `User.Read`,
|
|
195
|
+
`Mail.ReadWrite`, `Mail.Send`, `MailboxSettings.ReadWrite`, `Contacts.Read`,
|
|
196
|
+
`Calendars.Read`, `Files.Read.All` and `Sites.Read.All`. The last three are
|
|
197
|
+
read-only and let the same token read calendar events and search or download
|
|
198
|
+
OneDrive/SharePoint files through Graph. `Files.Read.All` and `Sites.Read.All`
|
|
199
|
+
are delegated permissions that need **admin consent** in the Azure app
|
|
200
|
+
registration on work/school tenants; `Calendars.Read` does not. See
|
|
201
|
+
[SETUP.md](./SETUP.md#admin-consent-for-filesreadall-and-sitesreadall).
|
|
202
|
+
`--readonly` keeps its smaller mail-only set.
|
|
203
|
+
|
|
204
|
+
### Reading
|
|
205
|
+
| Command | Purpose |
|
|
206
|
+
|---|---|
|
|
207
|
+
| `outlook me` | Your profile and primary address |
|
|
208
|
+
| `outlook list [--folder <name\|id>] [--unread] [--all] [--limit N]` | List messages |
|
|
209
|
+
| `outlook get <messageId> [--html]` | Full message, plain text by default |
|
|
210
|
+
| `outlook search <kql> [--folder <name\|id>] [--limit N]` | KQL search across the mailbox |
|
|
211
|
+
| `outlook folders list` | Mail folders with unread/total counts |
|
|
212
|
+
| `outlook attachments list <messageId>` | Attachments on a message |
|
|
213
|
+
| `outlook attachments download <messageId> <attachmentId> --out <path> [--force]` | Save an attachment |
|
|
214
|
+
|
|
215
|
+
Folder arguments accept the well-known names — `inbox`, `drafts`, `sentitems`,
|
|
216
|
+
`deleteditems`, `archive`, `junkemail`, and others — or a raw folder id.
|
|
217
|
+
|
|
218
|
+
### Sending
|
|
219
|
+
| Command | Purpose |
|
|
220
|
+
|---|---|
|
|
221
|
+
| `outlook send --to <addr> --subject <s> [--body <text> \| --body-file <path>] [--cc] [--bcc] [--html] [--attach <path>...] [--signature <path> \| --no-signature] [--no-save-sent]` | Send a message |
|
|
222
|
+
| `outlook reply <messageId> --body <text>` | Reply to the sender |
|
|
223
|
+
| `outlook reply-all <messageId> --body <text>` | Reply to everyone |
|
|
224
|
+
| `outlook forward <messageId> --to <addr> [--body <text>]` | Forward |
|
|
225
|
+
|
|
226
|
+
`--to`, `--cc`, `--bcc` and `--attach` are repeatable, and address flags also
|
|
227
|
+
accept comma-separated lists.
|
|
228
|
+
|
|
229
|
+
### Signatures
|
|
230
|
+
|
|
231
|
+
Microsoft Graph never adds your Outlook signature to mail sent through the API,
|
|
232
|
+
so the CLI appends one itself. Point it at a file once:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
echo 'OUTLOOK_SIGNATURE_FILE=/Users/you/.config/outlook/signature.txt' >> ~/.afk/config/afk.env
|
|
236
|
+
echo 'OUTLOOK_SIGNATURE_HTML_FILE=/Users/you/.config/outlook/signature.html' >> ~/.afk/config/afk.env # optional
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
- **On by default once configured** for `send`, `draft create`, `reply`,
|
|
240
|
+
`reply-all` and `forward`. `--no-signature` skips it for one message;
|
|
241
|
+
`--signature <path>` uses a different file (`.html`/`.htm` = HTML).
|
|
242
|
+
- **Plain-text bodies** get the RFC 3676 `-- ` delimiter and the text signature.
|
|
243
|
+
**HTML bodies** (`--html`) get the HTML signature, or the text one escaped.
|
|
244
|
+
An HTML-only setup with a plain-text body is an error, not a silent skip.
|
|
245
|
+
- **Replies and forwards** use Graph's plain-text `comment`, so they take the
|
|
246
|
+
text signature, placed above the quoted original.
|
|
247
|
+
- **No `--body`:** a reply, reply-all or forward without `--body` still sends the signature on its own, as Outlook does when you sign an empty reply.
|
|
248
|
+
- **`draft update` never adds one** (it would duplicate the create-time copy).
|
|
249
|
+
- **Visible, never silent:** `--dry-run` shows the appended text and `--json`
|
|
250
|
+
reports `signatureApplied`. An unreadable signature file is a hard error.
|
|
251
|
+
- `~/.afk/config/afk.env` is shared by every afk agent on the machine, so any
|
|
252
|
+
agent sending through this CLI will sign as you. Export the variable per
|
|
253
|
+
shell instead if that is not what you want.
|
|
254
|
+
- **HTML signature files are trusted and inserted verbatim** (no sanitisation).
|
|
255
|
+
Treat them like the token cache: restrict write access to the file owner
|
|
256
|
+
(`chmod 600 ~/sig.html`). Do not point the variable at world-writable paths.
|
|
257
|
+
|
|
258
|
+
### Drafts
|
|
259
|
+
| Command | Purpose |
|
|
260
|
+
|---|---|
|
|
261
|
+
| `outlook draft list` | List drafts |
|
|
262
|
+
| `outlook draft create --subject <s> [--to ...] [--body ...] [--attach ...]` | Create a draft |
|
|
263
|
+
| `outlook draft update <draftId> [--subject] [--body] [--to]` | Edit a draft |
|
|
264
|
+
| `outlook draft send <draftId>` | Send an existing draft |
|
|
265
|
+
|
|
266
|
+
Drafts are the safest way to send: compose, inspect, then send as a separate act.
|
|
267
|
+
|
|
268
|
+
### Organizing
|
|
269
|
+
| Command | Purpose |
|
|
270
|
+
|---|---|
|
|
271
|
+
| `outlook delete <messageId>` | Move to Deleted Items (**recoverable**) |
|
|
272
|
+
| `outlook purge <messageId> --yes` | Hard delete (**may be unrecoverable**) |
|
|
273
|
+
| `outlook move <messageId> --folder <name\|id>` | Move to a folder |
|
|
274
|
+
| `outlook mark-read <messageId>` / `mark-unread` | Set read state |
|
|
275
|
+
| `outlook flag <messageId>` / `unflag` | Set follow-up flag |
|
|
276
|
+
| `outlook folders create --name <text>` | Create a folder (Graph creates at root only) |
|
|
277
|
+
|
|
278
|
+
### Global flags
|
|
279
|
+
| Flag | Effect |
|
|
280
|
+
|---|---|
|
|
281
|
+
| `--json` | Machine-readable output on every command |
|
|
282
|
+
| `--dry-run` | Render the request, send nothing (mutating commands) |
|
|
283
|
+
| `--help` / `-h` | Usage for the command |
|
|
284
|
+
|
|
285
|
+
Pagination flags are **not** global — each command declares its own flags and
|
|
286
|
+
rejects anything else (see "Unknown flags fail closed" above). `--all` is
|
|
287
|
+
accepted by `list`, `search`, `folders`, `draft`, and `attachments`; `--limit N`
|
|
288
|
+
by `list`, `search`, and `draft`. The authoritative list is `COMMAND_FLAGS` in
|
|
289
|
+
[`src/flagSpec.ts`](./src/flagSpec.ts), and `outlook <command> --help` prints it.
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## Notes and limits
|
|
294
|
+
|
|
295
|
+
- **Attachments are inline only, under 3 MB.** Larger files need a Graph upload
|
|
296
|
+
session, which is not implemented; the CLI fails loudly with the actual file
|
|
297
|
+
size rather than truncating.
|
|
298
|
+
- **Message ids change when a message moves between folders.** All reads send
|
|
299
|
+
`Prefer: IdType="ImmutableId"` so stored ids stay stable, but an id captured
|
|
300
|
+
before a `move` should not be reused after it.
|
|
301
|
+
- **`$search` and `$orderby` are not combined.** Microsoft's docs do not confirm
|
|
302
|
+
that pairing for messages, so `search` omits ordering rather than emitting a
|
|
303
|
+
request that may be rejected.
|
|
304
|
+
- **`$orderby` must agree with `$filter`.** Graph returns `InefficientFilter`
|
|
305
|
+
when ordering references properties the filter does not; the CLI surfaces that
|
|
306
|
+
with an explanation.
|
|
307
|
+
|
|
308
|
+
## Development
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
pnpm test # file-size gate + vitest
|
|
312
|
+
pnpm typecheck # tsc --noEmit
|
|
313
|
+
pnpm build # tsup -> dist/cli.js
|
|
314
|
+
pnpm test:coverage
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Tests never touch the network and never send mail: the HTTP layer is injected,
|
|
318
|
+
and command tests run against a real client whose `fetch` throws, so any
|
|
319
|
+
unguarded network call fails the suite instead of quietly passing.
|
|
320
|
+
|
|
321
|
+
**No source file may exceed 350 lines** — enforced by
|
|
322
|
+
`scripts/check-file-size.sh`, wired into `pnpm test`. At the ceiling, extract a
|
|
323
|
+
concern into a new file rather than compressing an existing one.
|
|
324
|
+
|
|
325
|
+
API reference notes gathered during the build live in `docs/`.
|
package/SETUP.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# Setup — one-time Azure app registration
|
|
2
|
+
|
|
3
|
+
`outlook-cli` talks to your mailbox through the Microsoft Graph API. Microsoft
|
|
4
|
+
requires every Graph client to be a registered application, so there is exactly
|
|
5
|
+
one step a human has to do that no script can do for you: **register an app and
|
|
6
|
+
copy its client ID.**
|
|
7
|
+
|
|
8
|
+
It takes about three minutes, costs nothing, and needs no Azure subscription —
|
|
9
|
+
a plain Microsoft account (outlook.com, hotmail, live) or a work/school account
|
|
10
|
+
is enough.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Why this step exists
|
|
15
|
+
|
|
16
|
+
There is no "just give me an API key" path for Outlook mail. Microsoft issues
|
|
17
|
+
credentials to *applications*, not to users, and a token is only ever minted
|
|
18
|
+
after you personally sign in and consent. That is also what keeps this safe:
|
|
19
|
+
the CLI never sees your password, and you can revoke its access at any time
|
|
20
|
+
without changing your password.
|
|
21
|
+
|
|
22
|
+
The client ID you create is **not a secret**. It identifies the app, not you.
|
|
23
|
+
The device-code flow this CLI uses is designed for exactly this case — a
|
|
24
|
+
command-line tool on a machine you control, with no client secret to leak.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Step 1 — Open the app registration page
|
|
29
|
+
|
|
30
|
+
Go to <https://entra.microsoft.com> → **Applications** → **App registrations**.
|
|
31
|
+
|
|
32
|
+
Direct link: <https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade>
|
|
33
|
+
|
|
34
|
+
(The older <https://portal.azure.com> → *Azure Active Directory* → *App
|
|
35
|
+
registrations* path leads to the same screen.)
|
|
36
|
+
|
|
37
|
+
Sign in with the Microsoft account whose mail you want the CLI to read.
|
|
38
|
+
|
|
39
|
+
## Step 2 — New registration
|
|
40
|
+
|
|
41
|
+
Click **+ New registration**.
|
|
42
|
+
|
|
43
|
+
- **Name:** anything — `outlook-cli` is fine. Only you ever see it.
|
|
44
|
+
- **Supported account types:** choose
|
|
45
|
+
|
|
46
|
+
> **Accounts in any organizational directory (Any Microsoft Entra ID tenant —
|
|
47
|
+
> Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)**
|
|
48
|
+
|
|
49
|
+
This is the option that makes one registration work for BOTH a personal
|
|
50
|
+
outlook.com mailbox and a work/school Microsoft 365 mailbox. Picking a
|
|
51
|
+
narrower option is the single most common reason sign-in later fails with
|
|
52
|
+
*"unauthorized_client"*.
|
|
53
|
+
- **Redirect URI:** leave it **blank**. The device-code flow does not use one.
|
|
54
|
+
|
|
55
|
+
Click **Register**.
|
|
56
|
+
|
|
57
|
+
## Step 3 — Copy the client ID
|
|
58
|
+
|
|
59
|
+
On the app's **Overview** page, copy **Application (client) ID**. It looks like:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
11111111-2222-3333-4444-555555555555
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
That is your `GRAPH_CLIENT_ID`.
|
|
66
|
+
|
|
67
|
+
## Step 4 — Turn on public client flows
|
|
68
|
+
|
|
69
|
+
This is the step people miss, and it is required.
|
|
70
|
+
|
|
71
|
+
Go to **Authentication** (left sidebar) → scroll to **Advanced settings** →
|
|
72
|
+
**Allow public client flows** → set to **Yes** → **Save**.
|
|
73
|
+
|
|
74
|
+
Without this, `outlook auth login` fails with:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
AADSTS7000218: The request body must contain the following parameter:
|
|
78
|
+
'client_assertion' or 'client_secret'.
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
That error means precisely this toggle is off — not that you need a secret.
|
|
82
|
+
|
|
83
|
+
## Step 5 — Export the client ID and sign in
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
export GRAPH_CLIENT_ID=11111111-2222-3333-4444-555555555555
|
|
87
|
+
outlook auth login
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
You will see something like:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
To sign in, open https://microsoft.com/devicelogin and enter code ABCD-EFGH
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Open that URL, enter the code, sign in, and approve the permission list. The
|
|
97
|
+
CLI polls in the background and confirms once you are done. The token is cached
|
|
98
|
+
at `~/.afk/state/outlook/token.json` (mode `0600`) and refreshes itself from
|
|
99
|
+
then on — you should not need to log in again unless you revoke access.
|
|
100
|
+
|
|
101
|
+
Verify:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
outlook auth status
|
|
105
|
+
outlook me
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Making it permanent
|
|
111
|
+
|
|
112
|
+
Add the export to your shell profile, or drop it in AFK's env file so every
|
|
113
|
+
session picks it up:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
echo 'GRAPH_CLIENT_ID=11111111-2222-3333-4444-555555555555' >> ~/.afk/config/afk.env
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Optional variables (see `.env.example`):
|
|
120
|
+
|
|
121
|
+
| Variable | Default | Purpose |
|
|
122
|
+
|---|---|---|
|
|
123
|
+
| `GRAPH_CLIENT_ID` | *(required)* | Application (client) ID from Step 3 |
|
|
124
|
+
| `GRAPH_TENANT_ID` | `common` | `common` supports personal **and** work accounts |
|
|
125
|
+
| `GRAPH_CLIENT_SECRET` | *(unset)* | Only for confidential-client mode; leave blank |
|
|
126
|
+
| `GRAPH_TOKEN_CACHE` | `~/.afk/state/outlook/token.json` | Token cache location |
|
|
127
|
+
| `OUTLOOK_SIGNATURE_FILE` | *(unset)* | Plain-text signature appended to outgoing mail (see README "Signatures") |
|
|
128
|
+
| `OUTLOOK_SIGNATURE_HTML_FILE` | *(unset)* | HTML signature used for `--html` bodies |
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## What the CLI asks permission for
|
|
133
|
+
|
|
134
|
+
At first sign-in Microsoft shows a consent screen listing these scopes:
|
|
135
|
+
|
|
136
|
+
| Scope | Why it is needed |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `offline_access` | Refresh the token so you sign in once, not daily |
|
|
139
|
+
| `User.Read` | Read your own profile — powers `outlook me` |
|
|
140
|
+
| `Mail.ReadWrite` | Read messages, manage drafts, flags, folders |
|
|
141
|
+
| `Mail.Send` | Send, reply, forward |
|
|
142
|
+
| `MailboxSettings.ReadWrite` | Read/write mailbox settings (needed for `rules create`) |
|
|
143
|
+
| `Contacts.Read` | Read your contacts — powers `outlook contacts` |
|
|
144
|
+
| `Calendars.Read` | Read your calendar events through Graph |
|
|
145
|
+
| `Files.Read.All` | Read OneDrive and SharePoint files you can access (search and download) |
|
|
146
|
+
| `Sites.Read.All` | Read SharePoint sites, documents and list items you can access |
|
|
147
|
+
|
|
148
|
+
These are **delegated** permissions: the CLI can only ever do what *you* can do,
|
|
149
|
+
and only while your token is valid. `outlook auth login --readonly` requests a
|
|
150
|
+
smaller mail-only set (`offline_access`, `User.Read`, `Mail.Read`,
|
|
151
|
+
`MailboxSettings.Read`) and none of the calendar or file scopes.
|
|
152
|
+
|
|
153
|
+
### Admin consent for Files.Read.All and Sites.Read.All
|
|
154
|
+
|
|
155
|
+
`Files.Read.All` and `Sites.Read.All` are delegated permissions that need
|
|
156
|
+
**admin consent** in the Azure app registration on work/school tenants. Since
|
|
157
|
+
August 2025 Microsoft's default user-consent policy ("Let Microsoft manage your
|
|
158
|
+
consent settings") blocks users from consenting to them for third-party apps,
|
|
159
|
+
so sign-in fails at the consent screen until an admin grants them. A tenant
|
|
160
|
+
admin can do this once:
|
|
161
|
+
|
|
162
|
+
**App registrations** → your app → **API permissions** → **+ Add a permission**
|
|
163
|
+
→ **Microsoft Graph** → **Delegated permissions** → add `Files.Read.All` and
|
|
164
|
+
`Sites.Read.All` (add the other scopes in the table too) → **Grant admin
|
|
165
|
+
consent for <tenant>**.
|
|
166
|
+
|
|
167
|
+
`Calendars.Read` does not need admin consent; like the mail scopes, the user
|
|
168
|
+
approves it on the sign-in consent screen. Personal Microsoft accounts
|
|
169
|
+
(outlook.com) have no tenant admin and consent to all of these scopes
|
|
170
|
+
themselves.
|
|
171
|
+
|
|
172
|
+
After upgrading from a version without these scopes, run `outlook auth logout`
|
|
173
|
+
then `outlook auth login` so the new token carries them.
|
|
174
|
+
|
|
175
|
+
## Revoking access
|
|
176
|
+
|
|
177
|
+
<https://account.live.com/consent/Manage> (personal accounts) or your tenant's
|
|
178
|
+
*Enterprise applications* blade (work accounts). Revoking is immediate and does
|
|
179
|
+
not require deleting the app registration.
|
|
180
|
+
|
|
181
|
+
To just clear the local token:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
outlook auth logout
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Troubleshooting
|
|
190
|
+
|
|
191
|
+
| Symptom | Cause | Fix |
|
|
192
|
+
|---|---|---|
|
|
193
|
+
| `AADSTS7000218 ... client_assertion or client_secret` | Public client flows off | Step 4 |
|
|
194
|
+
| `AADSTS700016 ... application not found` | Wrong or unset `GRAPH_CLIENT_ID` | Re-copy from Step 3 |
|
|
195
|
+
| `unauthorized_client` on a personal account | Registration is single-tenant | Re-do Step 2 with the multitenant + personal option |
|
|
196
|
+
| `Missing required environment variable(s): GRAPH_CLIENT_ID` | Not exported | Step 5 |
|
|
197
|
+
| `InvalidAuthenticationToken` on a call | Token expired and refresh failed | `outlook auth login` |
|
|
198
|
+
| Sign-in works, mail calls 403 | Consent was declined or partial | `outlook auth logout` then log in again and accept all scopes |
|
|
199
|
+
| Consent screen says "Need admin approval" (`AADSTS90094` / `AADSTS65001`) | Tenant blocks user consent to `Files.Read.All` / `Sites.Read.All` | Ask a tenant admin to grant admin consent (see "Admin consent" above), or sign in with `--readonly` / `--scopes` to skip those scopes |
|