@dumbmice/pi-mailbridge 0.0.0-stage → 0.1.1

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 (111) hide show
  1. package/CHANGELOG.md +836 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -2
  4. package/bin/pi-mailbridge.mjs +31 -0
  5. package/config.example.json +54 -0
  6. package/dist/core/attachments.js +87 -0
  7. package/dist/core/authz.js +65 -0
  8. package/dist/core/commands.js +72 -0
  9. package/dist/core/config-freshness.js +24 -0
  10. package/dist/core/config.js +286 -0
  11. package/dist/core/deliveries.js +180 -0
  12. package/dist/core/discover.js +282 -0
  13. package/dist/core/header-encoding.js +70 -0
  14. package/dist/core/html.js +48 -0
  15. package/dist/core/inline-images.js +355 -0
  16. package/dist/core/mail-command.js +62 -0
  17. package/dist/core/mailer.js +126 -0
  18. package/dist/core/markdown.js +96 -0
  19. package/dist/core/oauth-device.js +119 -0
  20. package/dist/core/oauth-openid.js +48 -0
  21. package/dist/core/oauth.js +137 -0
  22. package/dist/core/prime.js +51 -0
  23. package/dist/core/reply-parse.js +134 -0
  24. package/dist/core/sent-index.js +135 -0
  25. package/dist/core/settle.js +122 -0
  26. package/dist/core/setup.js +100 -0
  27. package/dist/core/smoke.js +164 -0
  28. package/dist/core/status.js +56 -0
  29. package/dist/core/token-source.js +27 -0
  30. package/dist/core/token.js +196 -0
  31. package/dist/core/version.js +44 -0
  32. package/dist/daemon/api.js +46 -0
  33. package/dist/daemon/imapflow.js +75 -0
  34. package/dist/daemon/main.js +802 -0
  35. package/dist/daemon/process.js +270 -0
  36. package/dist/daemon/scan-queue.js +38 -0
  37. package/dist/daemon/script.js +44 -0
  38. package/dist/daemon/send-budget.js +30 -0
  39. package/dist/daemon/service.js +455 -0
  40. package/dist/daemon/source-proxy.js +64 -0
  41. package/dist/daemon/spawn.js +227 -0
  42. package/dist/daemon/watcher.js +308 -0
  43. package/dist/extension.js +1233 -0
  44. package/dist/store/journal.js +92 -0
  45. package/dist/store/mode-store.js +47 -0
  46. package/dist/store/oauth-store.js +50 -0
  47. package/dist/store/paths.js +33 -0
  48. package/dist/store/pending-store.js +212 -0
  49. package/dist/store/registry.js +61 -0
  50. package/dist/store/routes.js +223 -0
  51. package/dist/store/schema.js +93 -0
  52. package/dist/store/secret.js +70 -0
  53. package/dist/store/threads.js +145 -0
  54. package/dist/store/uid-cursor.js +112 -0
  55. package/docs/ARCHITECTURE.md +356 -0
  56. package/docs/MAPPING.md +201 -0
  57. package/docs/OAUTH.md +448 -0
  58. package/docs/SECURITY.md +273 -0
  59. package/docs/images/flow.svg +124 -0
  60. package/docs/images/notification.png +0 -0
  61. package/package.json +85 -4
  62. package/scripts/render-teaser.ts +56 -0
  63. package/src/core/attachments.ts +129 -0
  64. package/src/core/authz.ts +87 -0
  65. package/src/core/commands.ts +99 -0
  66. package/src/core/config-freshness.ts +33 -0
  67. package/src/core/config.ts +373 -0
  68. package/src/core/deliveries.ts +233 -0
  69. package/src/core/discover.ts +333 -0
  70. package/src/core/header-encoding.ts +75 -0
  71. package/src/core/html.ts +62 -0
  72. package/src/core/inline-images.ts +421 -0
  73. package/src/core/mail-command.ts +76 -0
  74. package/src/core/mailer.ts +193 -0
  75. package/src/core/markdown.ts +114 -0
  76. package/src/core/oauth-device.ts +195 -0
  77. package/src/core/oauth-openid.ts +61 -0
  78. package/src/core/oauth.ts +182 -0
  79. package/src/core/prime.ts +74 -0
  80. package/src/core/reply-parse.ts +153 -0
  81. package/src/core/sent-index.ts +164 -0
  82. package/src/core/settle.ts +179 -0
  83. package/src/core/setup.ts +139 -0
  84. package/src/core/smoke.ts +207 -0
  85. package/src/core/status.ts +75 -0
  86. package/src/core/token-source.ts +34 -0
  87. package/src/core/token.ts +240 -0
  88. package/src/core/version.ts +45 -0
  89. package/src/daemon/api.ts +52 -0
  90. package/src/daemon/imapflow.ts +101 -0
  91. package/src/daemon/main.ts +832 -0
  92. package/src/daemon/process.ts +348 -0
  93. package/src/daemon/scan-queue.ts +45 -0
  94. package/src/daemon/script.ts +45 -0
  95. package/src/daemon/send-budget.ts +36 -0
  96. package/src/daemon/service.ts +550 -0
  97. package/src/daemon/source-proxy.ts +88 -0
  98. package/src/daemon/spawn.ts +332 -0
  99. package/src/daemon/watcher.ts +427 -0
  100. package/src/extension.ts +1381 -0
  101. package/src/store/journal.ts +104 -0
  102. package/src/store/mode-store.ts +60 -0
  103. package/src/store/oauth-store.ts +55 -0
  104. package/src/store/paths.ts +38 -0
  105. package/src/store/pending-store.ts +282 -0
  106. package/src/store/registry.ts +91 -0
  107. package/src/store/routes.ts +267 -0
  108. package/src/store/schema.ts +104 -0
  109. package/src/store/secret.ts +69 -0
  110. package/src/store/threads.ts +177 -0
  111. package/src/store/uid-cursor.ts +135 -0
package/docs/OAUTH.md ADDED
@@ -0,0 +1,448 @@
1
+ # OAUTH.md — making Outlook, Microsoft 365 and Gmail work
2
+
3
+ ## The short version
4
+
5
+ Bad news only for passwords. **OAuth2 works.** Microsoft retired *Basic
6
+ Authentication* for `outlook.com`/`hotmail.com` on **2024-09-16**. App passwords can still
7
+ be created in some tenants, but **IMAP rejects them** and SMTP acceptance is inconsistent —
8
+ treat password auth as unusable for personal Microsoft accounts and use OAuth2. The fix is a token, and this package now gets
9
+ one for you with a device-code login.
10
+
11
+ ```bash
12
+ # 1. once: register an app and paste the client id into config.json
13
+ pi-mailbridge --check # tells you what is missing
14
+ # 2. once: approve on any device
15
+ pi-mailbridge --auth
16
+ # 3. from then on it refreshes itself
17
+ ```
18
+
19
+ ## One-time app registration
20
+
21
+ You cannot avoid this step: Microsoft (and Google) require a client id that a
22
+ human consented to. It is free and takes about two minutes.
23
+
24
+ 1. Open the [Entra admin center](https://entra.microsoft.com) → **Identity** →
25
+ **Applications** → **App registrations** → **New registration**
26
+ 2. Name it anything (`pi-mailbridge`)
27
+ 3. **Supported account types**: pick the one matching your account
28
+ - personal `@outlook.com` / `@hotmail.com` → *…and personal Microsoft accounts*
29
+ - work `@yourcompany.com` → *Accounts in this organizational directory only*
30
+ 4. **Redirect URI**: leave empty (device code needs none)
31
+ 5. Register, then copy **Application (client) ID**
32
+ 6. **Authentication** → *Allow public client flows* → **Yes**
33
+ (device code is a public client flow; without this the request is rejected)
34
+ 7. **API permissions** → *Add a permission* → *APIs my organization uses* is not
35
+ needed for personal accounts; for work accounts add **Microsoft Graph →
36
+ Delegated → `offline_access`, `IMAP.AccessAsUser.All`, `SMTP.Send`** and grant
37
+ consent
38
+
39
+ For Gmail: [Google Cloud console](https://console.cloud.google.com) → APIs &
40
+ Services → Credentials → *Create OAuth client ID* → **TVs and Limited Input
41
+ devices** (that is Google's name for device flow) → enable the Gmail API.
42
+
43
+ ## If Google is unreachable (mainland China)
44
+
45
+ Gmail is out twice over: no Google access, and phone verification you may not have.
46
+ The practical providers there authenticate with an **app-specific code**
47
+ (授权码 / authorization code) used as the password — so, like a Gmail app password,
48
+ there is no OAuth app, no tenant, and no consent screen:
49
+
50
+ | Provider | IMAP | SMTP | Notes |
51
+ | --- | --- | --- | --- |
52
+ | **163** (`163.com`) | `imap.163.com:993` | `smtp.163.com:465` | Enable in 设置 → POP3/SMTP/IMAP; SMS verification, then a 授权码. Requires the IMAP `ID` command, which this client sends automatically. **No waiting period** |
53
+ | **126 / yeah.net / 188.com** | same pattern | same pattern | NetEase family, same service and 授权码 rules |
54
+ | **QQ Mail** (`qq.com`, `foxmail.com`) | `imap.qq.com:993` | `smtp.qq.com:465` | Enable **IMAP/SMTP服务** in 设置 → 账户, then a 授权码. **New accounts must be 14 days old**: *"你的QQ邮箱激活时间不满14天,故暂时无法开启"* |
55
+
56
+ All three families are built into the discovery table, so they resolve even when the
57
+ autoconfig service is slow or blocked:
58
+
59
+ ```
60
+ /mailbridge setup pi-yourname@qq.com to you@hotmail.com
61
+ ```
62
+
63
+ Then paste the 授权码 into both password fields and run `/mailbridge smoke`.
64
+
65
+ **`Authentication-Results` on QQ.** Verified against a live mailbox: external mail
66
+ arrives with a header whose authserv-id is **`mx.qq.com`**, e.g.
67
+
68
+ ```
69
+ Authentication-Results: mx.qq.com; spf=pass(...) ...; dkim=pass(...) ...
70
+ ; dmarc=pass(p=NONE sp=NONE pct=100) header.from=your-provider.example
71
+ ```
72
+
73
+ Mail sent from QQ to QQ gets *no* header (same server, nothing to authenticate), so a
74
+ self-addressed test never exercises the DMARC path — use a reply from an external
75
+ address. If you want to pin the relay exactly, set
76
+ `security.trustedAuthservId: "mx.qq.com"`; leaving it unset uses the topmost header,
77
+ which is the safer default because a provider can change the id.
78
+
79
+ **One thing to watch:** if every reply is rejected with `dmarc-missing`, the
80
+ provider is not adding an `Authentication-Results` header. This is the normal case for a
81
+ self-addressed (single-mailbox) setup, which is why `/mailbridge setup` turns DMARC off when
82
+ both addresses match. Set
83
+ `security.requireDmarcPass: false` — the sender allowlist still applies, so this
84
+ loses a defence layer rather than all of them.
85
+
86
+ ## Why can't I log into Entra with my new Hotmail address?
87
+
88
+ Because it is two different systems:
89
+
90
+ | | What it is | What it's for |
91
+ | --- | --- | --- |
92
+ | **Microsoft account (MSA)** | the identity behind `you@hotmail.com` | signing in to Outlook, and the **device-code flow** |
93
+ | **Microsoft Entra tenant** | a *directory* that holds app registrations | `entra.microsoft.com`, `portal.azure.com` |
94
+
95
+ A brand-new Hotmail address has **no tenant**. When you sign in to the Entra
96
+ admin centre with it, Microsoft drops you into the shared **"Microsoft Services"
97
+ tenant**, where you are not a member — hence "this account does not exist".
98
+ Microsoft's own answer says it plainly:
99
+
100
+ > Within the default tenant, there is no linked directory for administrative
101
+ > actions such as app registration.
102
+
103
+ Nothing is wrong with your mailbox. You just do not have a directory to put an app in.
104
+
105
+ ### Your three options, with the friction measured
106
+
107
+ | | Outlook (personal) | Gmail |
108
+ | --- | --- | --- |
109
+ | **App password** | ⚠️ can still be created; **IMAP rejects it** (SMTP may accept it) | ✅ 16-char passcode, works for both directions |
110
+ | **OAuth app needed** | ✅ plus an Entra **tenant** | ✅ plus a GCP project |
111
+ | **OAuth friction** | no tenant → "account does not exist" | mail scopes are **restricted**: verification **+ annual CASA** to publish; a consent screen left in **Testing** expires refresh tokens **every 7 days** (`invalid_grant`) |
112
+ | **Servers** | sometimes refuse IMAP/SMTP OAuth2 outright | reliable |
113
+
114
+ That table is the whole argument: **Gmail is easier because of app passwords, not
115
+ because of OAuth.** With an app password there is no OAuth app, no tenant, no
116
+ verification, and no token that dies weekly.
117
+
118
+ **A. Gmail + app password (recommended).**
119
+
120
+ 1. Enable **2-Step Verification** on the Google account (required; app passwords
121
+ do not exist without it)
122
+ 2. <https://myaccount.google.com/apppasswords> → create one → copy the 16 characters
123
+ 3. Run setup, then paste the passcode into both password fields:
124
+
125
+ ```
126
+ /mailbridge setup pi-yourname@gmail.com to you@hotmail.com force
127
+ ```
128
+
129
+ `--setup` picks `LOGIN` for Google automatically and attaches no OAuth block, so
130
+ there is nothing to register.
131
+
132
+ **B. Get a free Entra tenant for the Hotmail account.** Signing up for Azure with a
133
+ personal Microsoft account creates a **default directory** where you are Global
134
+ Administrator, and app registration then works there. Two routes:
135
+
136
+ *Global Azure free account* — <https://azure.microsoft.com/free/>:
137
+
138
+ 1. Sign in with the pi Hotmail account (not via the Entra admin centre)
139
+ 2. Complete identity verification (phone) and the profile step; a card is usually
140
+ required as a payment-method check even for the free tier
141
+ 3. On completion Azure creates the default directory. Sign in to
142
+ <https://entra.microsoft.com> and you should now see it in the directory picker
143
+ 4. **App registrations → New registration**
144
+
145
+ *Microsoft 365 Developer Program* — **does not work for a personal account.**
146
+ Joining is free, but the E5 sandbox is gated: Microsoft grants it only to
147
+
148
+ - active **Visual Studio Professional or Enterprise** subscribers
149
+ - **ISV Success** program members
150
+ - **AI Cloud Partner** program participants
151
+ - **Premier or Unified Support** plan members
152
+
153
+ A personal `@outlook.com`/`@hotmail.com` account that joins the program gets the
154
+ learning resources and a Graph tutorial, and **no dashboard option to provision a
155
+ sandbox** — eligibility is decided server-side and cannot be requested. If you
156
+ joined hoping for a tenant, that route is closed.
157
+
158
+ So the only remaining way to obtain a tenant for a personal mailbox is the **Azure
159
+ free account** above, which is why it asks for a card.
160
+
161
+ ### If you do end up with a work/school tenant
162
+
163
+ A tenant mailbox is not equivalent to the personal one:
164
+
165
+ | pi mailbox | Authority | Risk |
166
+ | --- | --- | --- |
167
+ | `pi-xyz@hotmail.com` (personal) | `consumers/v2.0` | the personal-Outlook IMAP/SMTP refusals below |
168
+ | `pi@yourtenant.onmicrosoft.com` (work) | `organizations/v2.0` | tied to the tenant; a sandbox renews every 90 days |
169
+
170
+ The app must list **personal Microsoft accounts** in its supported account types for
171
+ the `consumers` row to work; for the `organizations` row, any tenant member will do.
172
+ `--setup` picks the right authority from the address suffix automatically.
173
+
174
+ > **Do not use Azure China / 21Vianet.** `azure.cn` is a physically and logically
175
+ > isolated sovereign cloud with its own identity endpoints
176
+ > (`login.partner.microsoftonline.cn`). An app registered in a 21Vianet tenant
177
+ > **cannot** authenticate against global Outlook.com — the device flow against
178
+ > `login.microsoftonline.com` will fail. You need the global cloud, i.e.
179
+ > `azure.microsoft.com` / `login.microsoftonline.com`. `--check` reports it and exits non-zero if
180
+ > `oauth.issuer` points at a sovereign-cloud domain.
181
+
182
+ > Caveat: if the Microsoft account already belongs to a tenant as a member or
183
+ > guest, Azure signup joins that tenant instead of creating a fresh directory.
184
+ > A brand-new personal account gets its own.
185
+
186
+ Whatever tenant holds the app, set **Supported account types** to include *personal Microsoft accounts* so the Hotmail identity can sign in, and
187
+ **Allow public client flows → Yes**.
188
+
189
+ **C. Use a GCP project for Gmail OAuth.** Only worth it if you object to app
190
+ passwords. You must either accept **7-day refresh tokens** in Testing status (the
191
+ daemon re-authorises weekly) or publish the app — and reading mail needs restricted
192
+ scopes, which means verification plus a recurring CASA assessment.
193
+
194
+ > Trade-off worth naming: Google calls app passwords "not recommended and
195
+ > unnecessary in most cases" and could retire them. OAuth is the durable path; this
196
+ > package supports both. For a personal mailbox today, the app password is simply
197
+ > the one that works without a week of paperwork.
198
+
199
+ ### What the app registration needs, wherever it lives
200
+
201
+ - **Allow public client flows → Yes** (device code is a public-client flow;
202
+ without it you get `AADSTS7000218: the request body must contain 'client_assertion'`)
203
+ - Supported account types including **personal Microsoft accounts**, if the pi
204
+ mailbox is `@hotmail.com`/`@outlook.com`
205
+ - No client secret, no redirect URI
206
+
207
+ ## Do I need a paid Entra license?
208
+
209
+ **No.** Entra ID **Free** is what this needs, and it is included with any Azure
210
+ subscription or Microsoft 365 tenant at no charge.
211
+
212
+ | What | Tier | Cost |
213
+ | --- | --- | --- |
214
+ | App registration (client id) | Free | **$0** |
215
+ | Device-code / PKCE / OAuth2 token issuance | Free | **$0** |
216
+ | Delegated IMAP/SMTP/Graph scopes | Free | **$0** |
217
+ | Conditional Access, PIM, Identity Protection, risk policies | **P1 / P2** | paid — **not needed** |
218
+
219
+ Microsoft's own answers say it twice: *"Simply registering app registrations in
220
+ Entra ID does not incur any cost … as long as you're within the limits of your
221
+ Entra ID license (Free, P1, or P2)"*, and *"you can continue to use these
222
+ credentials … after your trial ends, as long as the tenant and the app registration
223
+ remain active"*.
224
+
225
+ So if a portal page offers **"Microsoft Entra ID P2 — one month free, then $10.50
226
+ per user per month"**, decline it. It is a premium add-on for enterprise identity
227
+ features; nothing in this package uses them. If you already activated a P2 trial,
228
+ just let it lapse — the app registration keeps working on Free.
229
+
230
+ ### There is no card-free route to a tenant
231
+
232
+ Stated once, plainly, because it saves a lot of wasted effort:
233
+
234
+ - **The Azure free account requires a card.** Microsoft's supported-payment-methods
235
+ page excludes **prepaid** and virtual cards; a non-prepaid Visa/Mastercard — and in
236
+ some regions a non-prepaid **UnionPay** debit or credit card — is required, with a
237
+ billing address matching the card.
238
+ - **A free or trial tenant cannot create another tenant.** Microsoft: *"Only paid
239
+ customers can create a new Workforce tenant … Customers using a free tenant, or a
240
+ trial subscription won't be able to create additional tenants."* So there is no
241
+ back door from inside the portal.
242
+ - **The Developer Program sandbox is gated** to paying program members (see above).
243
+
244
+ No card, no tenant. No tenant, no app registration. No app registration, no OAuth2,
245
+ and personal `outlook.com`/`hotmail.com` has no password fallback. That is the whole
246
+ chain, and it breaks at the first link.
247
+
248
+ **Consequence:** if you cannot or will not attach a card, a personal Microsoft
249
+ mailbox cannot be used by this package. Use QQ Mail, Foxmail, 163 or 126 with a 授权码
250
+ instead — no card, no tenant, no OAuth — and keep the Microsoft address as the one
251
+ you *read* mail on (`mail.to`).
252
+
253
+ ### What happens when the Azure free trial ends
254
+
255
+ The free account gives 30 days and a credit. If you do **not** upgrade to
256
+ pay-as-you-go, the *subscription* expires — but the **directory survives**, and
257
+ Entra ID Free keeps working, so the app registration and its refresh tokens
258
+ continue to function. That is the documented separation between a subscription and
259
+ its associated directory.
260
+
261
+ The thing to keep alive is the *billing account*, not the subscription's resources.
262
+ Nothing in this package consumes Azure resources, so there is nothing to bill.
263
+
264
+ ### You do not need the $200 credit, and nothing must run in 30 days
265
+
266
+ The free account is the cheapest way to obtain a **directory**. The $200 credit is
267
+ for Azure *resources* — virtual machines, storage, databases — and this package uses
268
+ **none of them**. The whole requirement is the tenant that Azure creates as a side
269
+ effect of the signup.
270
+
271
+ So:
272
+
273
+ - **Create no Azure resources.** Not one. There is nothing to deploy; the daemon runs
274
+ on your own machine.
275
+ - **Losing the unused credit is irrelevant.** It expires after 30 days and you will
276
+ never miss it, because you were never going to spend it.
277
+ - **You do not have to “use” Azure within 30 days.** Signing up and registering the
278
+ app is the whole engagement.
279
+ - The card is an identity check, not a purchase. With no resources provisioned there
280
+ is nothing to charge.
281
+
282
+ If you want the directory to be maximally durable, the cheap insurance is: at the
283
+ 30-day mark, upgrade to **pay-as-you-go** *without creating anything*. A subscription
284
+ with zero resources bills zero, and it removes any doubt about the directory's
285
+ longevity. Doing nothing also works per Microsoft's documentation — the directory
286
+ outlives the subscription — but an active, empty subscription is the less ambiguous
287
+ state.
288
+
289
+ ## Configuration
290
+
291
+ ```json
292
+ {
293
+ "mail": {
294
+ "imap": { "host": "outlook.office365.com", "port": 993, "secure": true,
295
+ "user": "you@outlook.com", "password": "", "authType": "XOAUTH2" },
296
+ "smtp": { "host": "smtp-mail.outlook.com", "port": 587, "secure": false,
297
+ "user": "you@outlook.com", "password": "", "authType": "XOAUTH2" },
298
+ "to": "you@outlook.com"
299
+ },
300
+ "security": {
301
+ "allowFrom": ["you@outlook.com"],
302
+ "allowSelfReplies": true,
303
+ "requireDmarcPass": false
304
+ },
305
+ "oauth": {
306
+ "issuer": "https://login.microsoftonline.com/consumers/v2.0",
307
+ "clientId": "<application (client) id>",
308
+ "scope": "offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send"
309
+ }
310
+ }
311
+ ```
312
+
313
+ Presets (`core/discover.ts`, where `setup` reads them):
314
+
315
+ | Preset | `issuer` | `scope` |
316
+ | --- | --- | --- |
317
+ | **Microsoft personal** | `https://login.microsoftonline.com/consumers/v2.0` | `offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send` |
318
+ | **Microsoft 365** | `https://login.microsoftonline.com/organizations/v2.0` | same as above |
319
+ | **Google** | `https://accounts.google.com` | `https://mail.google.com/ offline_access` |
320
+
321
+ `offline_access` is not optional — without it the provider returns no refresh
322
+ token and you would re-approve every hour. `--check` reports it as a problem, with a non-zero exit.
323
+
324
+ ### Scopes are not interchangeable
325
+
326
+ Two different worlds, and mixing them is the most common cause of "the token
327
+ works but the server rejects it":
328
+
329
+ - **IMAP/SMTP XOAUTH2** needs `https://outlook.office.com/IMAP.AccessAsUser.All`
330
+ and `https://outlook.office.com/SMTP.Send`
331
+ - **Graph API** needs `https://graph.microsoft.com/Mail.Read` / `Mail.Send`
332
+
333
+ ## The login flow
334
+
335
+ `pi-mailbridge --auth` prints a URL and a code, then waits:
336
+
337
+ ```
338
+ Approve this login:
339
+ https://microsoft.com/devicelogin?code=ABCD1234
340
+ Code: ABCD1234
341
+
342
+ Waiting for approval…
343
+ ```
344
+
345
+ Approve from **any device** — phone, tablet, the laptop you actually have open.
346
+ That is the point of device code: no browser, no redirect listener, no port on the
347
+ daemon's machine.
348
+
349
+ The refresh token is stored in `$XDG_STATE_HOME/pi-mailbridge/oauth.json` (default `~/.local/state/…`) at mode
350
+ `0600`. Access tokens are cached in memory and refreshed shortly before expiry.
351
+
352
+ On the SMTP side, one 401 triggers exactly one token refresh and one retry. On the IMAP
353
+ side there is no such invalidation: a rejected token fails the dial and the watcher
354
+ re-dials on the normal backoff (capped at 60 s), so a revoked grant shows up as repeating
355
+ auth errors in `daemon.log` rather than a single failure - and the daemon does not stop.
356
+ The remedy is `/mailbridge auth` (re-authorise) followed by `/mailbridge restart`.
357
+
358
+ ## When IMAP/SMTP OAuth2 still fails
359
+
360
+ ### Enabling IMAP/SMTP in Outlook.com does not restore password login
361
+
362
+ Worth stating plainly, because it is the obvious thing to try: the toggle
363
+ (*Settings → Mail → Forwarding and IMAP → "Let devices and apps use IMAP"*) makes
364
+ the **protocols available**. It does not change **authentication**.
365
+
366
+ Microsoft's own [POP, IMAP and SMTP settings page](https://support.microsoft.com/en-us/outlook/pop-imap-and-smtp-settings-for-outlook-com)
367
+ says both things in adjacent sentences:
368
+
369
+ > POP & IMAP access is **disabled by default**. See the section below on how to
370
+ > enable POP or IMAP access in Outlook.com.
371
+
372
+ > **Outlook.com requires the use of Modern Auth / OAuth2.**
373
+
374
+ and its settings table lists the authentication method for every protocol:
375
+
376
+ | Protocol | Server | Authentication Method |
377
+ | --- | --- | --- |
378
+ | IMAP | `outlook.office365.com:993` | **OAuth2/Modern Auth** |
379
+ | POP | `outlook.office365.com:995` | **OAuth2/Modern Auth** |
380
+ | SMTP | `smtp-mail.outlook.com:587` | **OAuth2/Modern Auth** |
381
+
382
+ So enabling the toggle is necessary and not sufficient: it opens the port, and
383
+ OAuth2 is still the only way in.
384
+
385
+ **Why the internet disagrees with itself.** That same page also carries a stale
386
+ sentence — *"if your password is not being recognized … you may need an app
387
+ password"* — left over from before the shutdown. That sentence is why forum
388
+ threads confidently report that app passwords still work. Field reports split on
389
+ SMTP specifically (some clients still succeed with an app password, Microsoft's
390
+ Q&A said in 2025 that they were disabled, n8n's docs say not to rely on them). For
391
+ IMAP there is no ambiguity: **app passwords are not a workaround**.
392
+
393
+ That is what settles it here, because IMAP is how a reply reaches you. A tool that
394
+ could only send would not be this tool.
395
+
396
+ #### What to actually do about a personal Outlook mailbox
397
+
398
+ 1. **Get an Entra tenant** (free Azure signup) and register the app — the OAuth
399
+ route. Enabling IMAP/SMTP was prerequisite one; the tenant is prerequisite two.
400
+ 2. **Or use a provider that still takes a password** — Gmail app password, or
401
+ QQ/163 with a 授权码. Both avoid OAuth entirely.
402
+
403
+ Given the tenant friction *and* the server-side refusal reports below, option 2 is
404
+ usually the shorter path.
405
+
406
+ ### The server may refuse a valid token
407
+
408
+ This is the honest part, and it applies to **personal** `outlook.com` only:
409
+
410
+ | Symptom | Cause | Fix |
411
+ | --- | --- | --- |
412
+ | `User is authenticated but not connected` after a successful OAuth2 flow | a server-side IMAP regression for personal accounts reported since Dec 2024 | use the Graph API |
413
+ | `535 Authentication unsuccessful, SmtpClientAuthentication is disabled for the Mailbox` | SMTP AUTH disabled at the mailbox level, even though IMAP works | use the Graph API |
414
+ | Works on IMAP but not SMTP, or vice versa | per-protocol policy | use the Graph API for both |
415
+
416
+ **The reliable answer for personal Outlook is Graph** (`POST /me/sendMail`,
417
+ `GET /me/mailFolders/inbox/messages/delta`) because it never speaks SMTP. It is a
418
+ free API, the same OAuth token works, and it needs no extra package — Node 22 has
419
+ global `fetch`. It is not implemented in this package yet; see the *Status* section of the README.
420
+
421
+ Microsoft 365 (work) accounts usually work fine on IMAP/SMTP XOAUTH2.
422
+
423
+ ## Alternative: a token command
424
+
425
+ If you already have a token broker, skip the built-in flow:
426
+
427
+ ```json
428
+ "authType": "XOAUTH2",
429
+ "tokenCommand": ["ortie", "token", "show", "-a", "outlook"]
430
+ ```
431
+
432
+ The command is executed as argv (never a shell), its stdout is the token, and the
433
+ result is cached until near expiry. Three consecutive failures disable OAuth2 for the
434
+ session rather than spawning a failing process on every scan; a single transient failure is
435
+ retried.
436
+
437
+ Precedence: **`oauth.*` beats `tokenCommand`**. Remove the `oauth` block to switch
438
+ back.
439
+
440
+ ## Security
441
+
442
+ - The refresh token is equivalent to your password for mail. It is `0600` inside a
443
+ `0700` directory and is never logged, printed, or included in an email.
444
+ - Revoke access at any time: Microsoft **My Account → Privacy → App permissions**
445
+ (or Google **Security → Third-party access**). The stored token then stops
446
+ working and `--auth` is needed again.
447
+ - Rotating `secret.key` does **not** revoke OAuth; they are separate secrets.
448
+ - Prefer a dedicated mailbox for this package, as with everything else here.