dsh-email 0.10.7 → 0.11.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/README.en.md +81 -8
- package/README.md +61 -9
- package/lib/client.js +2248 -182
- package/lib/config.d.ts +141 -1
- package/lib/config.js +231 -10
- package/lib/index.d.ts +6 -2
- package/lib/index.js +3 -2
- package/lib/mail-client.d.ts +90 -1
- package/lib/mail-client.js +214 -33
- package/lib/oauth2.d.ts +124 -0
- package/lib/oauth2.js +417 -0
- package/lib/runtime.js +16 -3
- package/lib/settings.d.ts +22 -1
- package/lib/settings.js +122 -34
- package/lib/tool-contract.js +1 -1
- package/lib/tools.js +20 -5
- package/lib/web.d.ts +186 -2
- package/lib/web.js +861 -14
- package/package.json +1 -1
package/lib/oauth2.js
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outlook / Microsoft 365 OAuth2 authentication (device-code flow) for the
|
|
3
|
+
* `outlook` provider.
|
|
4
|
+
*
|
|
5
|
+
* Microsoft retired basic (password) authentication for IMAP/SMTP on Exchange
|
|
6
|
+
* Online, so an outlook account authenticates with an OAuth2 access token
|
|
7
|
+
* instead of a password. This module owns three things and nothing else:
|
|
8
|
+
*
|
|
9
|
+
* 1. the device-code login (`startDeviceFlow` / `pollDeviceFlow`), which is
|
|
10
|
+
* what the settings page drives with「登录」and「我已完成登录」;
|
|
11
|
+
* 2. the on-disk token store (`~/.dsh/data/dsh-email/oauth2-tokens.json`,
|
|
12
|
+
* i.e. `$DSH_HOME/data/dsh-email/oauth2-tokens.json` when DSH_HOME is set);
|
|
13
|
+
* 3. `getFreshAccessToken`, which hands the connection code a usable access
|
|
14
|
+
* token: reuse while it is comfortably fresh, refresh otherwise, single
|
|
15
|
+
* flight per account so concurrent callers share one token request.
|
|
16
|
+
*
|
|
17
|
+
* Tokens deliberately never touch the settings YAML, never enter the pool
|
|
18
|
+
* fingerprint and are never logged. Every message that leaves this module is
|
|
19
|
+
* the text the user reads in Chinese, without any token material in it.
|
|
20
|
+
*/
|
|
21
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { homedir } from 'node:os';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
import { clampInt, OUTLOOK_OAUTH2_CLIENT_ID } from './config.js';
|
|
25
|
+
/** The authority that serves both endpoints. `common` accepts any work/school/personal tenant. */
|
|
26
|
+
export const OAUTH2_TENANT = 'common';
|
|
27
|
+
export const DEVICE_CODE_URL = 'https://login.microsoftonline.com/' + OAUTH2_TENANT + '/oauth2/v2.0/devicecode';
|
|
28
|
+
export const TOKEN_URL = 'https://login.microsoftonline.com/' + OAUTH2_TENANT + '/oauth2/v2.0/token';
|
|
29
|
+
/**
|
|
30
|
+
* The delegated permissions the plugin needs: offline_access (a refresh token),
|
|
31
|
+
* IMAP.AccessAsUser.All and SMTP.Send. All three must be granted on the app
|
|
32
|
+
* registration, otherwise the token request answers AADSTS65001.
|
|
33
|
+
*/
|
|
34
|
+
export const OAUTH2_SCOPES = [
|
|
35
|
+
'offline_access',
|
|
36
|
+
'https://outlook.office.com/IMAP.AccessAsUser.All',
|
|
37
|
+
'https://outlook.office.com/SMTP.Send',
|
|
38
|
+
];
|
|
39
|
+
export const OAUTH2_SCOPE_TEXT = OAUTH2_SCOPES.join(' ');
|
|
40
|
+
/** Reuse an access token only while this much of its life is left. */
|
|
41
|
+
export const ACCESS_TOKEN_MARGIN_MS = 2 * 60 * 1000;
|
|
42
|
+
/** Every request to the authority is bounded: a hung login must not hang a mail tool. */
|
|
43
|
+
export const OAUTH2_REQUEST_TIMEOUT_MS = 15000;
|
|
44
|
+
/** The one message every「no token yet」path reports, so the fix is always the same. */
|
|
45
|
+
export const NOT_LOGGED_IN_MESSAGE = '尚未登录:请先在设置页完成设备码登录';
|
|
46
|
+
/**
|
|
47
|
+
* Reported when an OAuth2 account has no application to log in through. The
|
|
48
|
+
* plugin ships no third-party registration, so this is a setup step, not a
|
|
49
|
+
* failure — and it says where to go and what to type.
|
|
50
|
+
*/
|
|
51
|
+
export const NO_CLIENT_ID_MESSAGE = '尚未配置 OAuth2 应用:请在设置页该账号的「应用(客户端)ID」里填入你自己注册的 Azure 公共客户端 ID(免费,注册步骤见 README 的「Outlook OAuth2」一节)。本插件不内置任何第三方应用注册,因此没有它就无法开始设备码登录';
|
|
52
|
+
/** Where the refresh/access tokens live. Kept out of the settings namespace on purpose. */
|
|
53
|
+
export function oauth2TokenFile() {
|
|
54
|
+
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
55
|
+
return join(home, 'data', 'dsh-email', 'oauth2-tokens.json');
|
|
56
|
+
}
|
|
57
|
+
/** Raised for every expected OAuth2 failure; the message is already user-facing Chinese. */
|
|
58
|
+
export class OAuth2Error extends Error {
|
|
59
|
+
constructor(message) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = 'OAuth2Error';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const pending = new Map();
|
|
65
|
+
const starting = new Map();
|
|
66
|
+
const refreshing = new Map();
|
|
67
|
+
// --- AADSTS / OAuth error mapping -------------------------------------------
|
|
68
|
+
/**
|
|
69
|
+
* Actionable Chinese for the AADSTS codes a device-code login actually runs
|
|
70
|
+
* into. Anything not listed falls back to the authority's own description.
|
|
71
|
+
*/
|
|
72
|
+
const AADSTS_MESSAGES = {
|
|
73
|
+
'7000218': '应用注册需开启「允许公共客户端流」(Entra → 应用注册 → 身份验证 → 高级设置 → 允许公共客户端流 = 是)',
|
|
74
|
+
'65001': '权限未授予:请在 Entra → 应用注册 → API 权限 中为 IMAP.AccessAsUser.All 与 SMTP.Send 授予管理员同意',
|
|
75
|
+
'700016': '找不到该应用注册:clientId 可能填错了',
|
|
76
|
+
'700011': '应用注册的标识符无效:请在 Entra 确认该应用仍存在且允许多租户/个人账号',
|
|
77
|
+
'700022': '设备码无效或已被使用,请重新发起登录',
|
|
78
|
+
'70008': '刷新令牌已过期,请到设置页重新登录',
|
|
79
|
+
'700082': '登录会话已过期,请到设置页重新登录',
|
|
80
|
+
'700003': '登录会话已失效,请重新发起登录',
|
|
81
|
+
'7000215': 'client secret 无效:本插件使用公共客户端 + 设备码,应用注册里不要填写客户端密码',
|
|
82
|
+
'50034': '该用户不存在:请检查账号里填写的邮箱地址',
|
|
83
|
+
'53003': '条件访问策略拦下了登录:需在 Entra 中调整该用户的访问策略',
|
|
84
|
+
'50076': '需要多重身份验证:请在浏览器登录页完成 MFA 后再试',
|
|
85
|
+
'90002': '该账号在此租户没有邮箱,或用户未分配 Exchange 邮箱',
|
|
86
|
+
};
|
|
87
|
+
/** The Chinese guidance for an AADSTS code, or undefined when it is not mapped. */
|
|
88
|
+
export function mapAadstsMessage(code) {
|
|
89
|
+
return AADSTS_MESSAGES[String(code).trim()];
|
|
90
|
+
}
|
|
91
|
+
/** Extract `error`, `error_description` and the AADSTS number from an error payload. */
|
|
92
|
+
function aadstsOf(doc, description) {
|
|
93
|
+
const codes = Array.isArray(doc.error_codes) ? doc.error_codes : [];
|
|
94
|
+
for (const candidate of codes) {
|
|
95
|
+
if (typeof candidate === 'number' || (typeof candidate === 'string' && candidate !== ''))
|
|
96
|
+
return String(candidate);
|
|
97
|
+
}
|
|
98
|
+
const match = description.match(/AADSTS(\d+)/);
|
|
99
|
+
return match === null ? '' : match[1];
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Turn one failed token/device-code response into a verdict. `pending` is a
|
|
103
|
+
* normal state, not an error: the user simply has not finished in the browser.
|
|
104
|
+
*/
|
|
105
|
+
export function classifyOAuthFailure(payload, httpStatus = 0) {
|
|
106
|
+
const doc = (payload !== null && typeof payload === 'object' ? payload : {});
|
|
107
|
+
const code = typeof doc.error === 'string' ? doc.error : '';
|
|
108
|
+
const description = typeof doc.error_description === 'string' ? doc.error_description : '';
|
|
109
|
+
const aadsts = aadstsOf(doc, description);
|
|
110
|
+
const base = { pending: false, slowDown: false, clearToken: false, message: '', code };
|
|
111
|
+
if (code === 'authorization_pending')
|
|
112
|
+
return { ...base, pending: true };
|
|
113
|
+
if (code === 'slow_down')
|
|
114
|
+
return { ...base, pending: true, slowDown: true };
|
|
115
|
+
if (code === 'expired_token')
|
|
116
|
+
return { ...base, message: '设备码超时,请重新发起登录' };
|
|
117
|
+
if (code === 'authorization_declined')
|
|
118
|
+
return { ...base, message: '你在登录页拒绝了授权,请重新发起登录' };
|
|
119
|
+
if (code === 'bad_verification_code')
|
|
120
|
+
return { ...base, message: '设备码无效或已被使用,请重新发起登录' };
|
|
121
|
+
if (code === 'invalid_grant') {
|
|
122
|
+
const mapped = mapAadstsMessage(aadsts);
|
|
123
|
+
return {
|
|
124
|
+
...base,
|
|
125
|
+
clearToken: true,
|
|
126
|
+
message: mapped !== undefined ? mapped + '(AADSTS' + aadsts + ')' : '登录已失效(刷新令牌过期或被撤销),请到设置页重新登录',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (code === 'invalid_client' || code === 'unauthorized_client') {
|
|
130
|
+
const mapped = mapAadstsMessage(aadsts);
|
|
131
|
+
return {
|
|
132
|
+
...base,
|
|
133
|
+
message: mapped !== undefined
|
|
134
|
+
? mapped + '(AADSTS' + aadsts + ')'
|
|
135
|
+
: 'clientId 无效,或该应用注册不允许设备码流(需开启公共客户端流)',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const mapped = mapAadstsMessage(aadsts);
|
|
139
|
+
if (mapped !== undefined)
|
|
140
|
+
return { ...base, message: mapped + '(AADSTS' + aadsts + ')' };
|
|
141
|
+
const detail = description !== '' ? description : (code !== '' ? code : 'HTTP ' + httpStatus);
|
|
142
|
+
return { ...base, message: '登录失败:' + detail + (aadsts !== '' ? '(AADSTS' + aadsts + ')' : '') };
|
|
143
|
+
}
|
|
144
|
+
// --- token store -------------------------------------------------------------
|
|
145
|
+
/** Read the store. A missing or unreadable file is 「no tokens」, never a crash. */
|
|
146
|
+
export function readTokenStore() {
|
|
147
|
+
let doc;
|
|
148
|
+
try {
|
|
149
|
+
doc = JSON.parse(readFileSync(oauth2TokenFile(), 'utf8'));
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return { version: 1, accounts: {} };
|
|
153
|
+
}
|
|
154
|
+
if (doc === null || typeof doc !== 'object' || Array.isArray(doc))
|
|
155
|
+
return { version: 1, accounts: {} };
|
|
156
|
+
const raw = doc.accounts;
|
|
157
|
+
const accounts = {};
|
|
158
|
+
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
159
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
160
|
+
const entry = tokenEntryOf(value);
|
|
161
|
+
if (entry !== undefined)
|
|
162
|
+
accounts[name] = entry;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { version: 1, accounts };
|
|
166
|
+
}
|
|
167
|
+
/** Keep only well-formed entries: a half-written file must not poison a login. */
|
|
168
|
+
function tokenEntryOf(value) {
|
|
169
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
170
|
+
return undefined;
|
|
171
|
+
const doc = value;
|
|
172
|
+
if (typeof doc.refreshToken !== 'string' || doc.refreshToken === '')
|
|
173
|
+
return undefined;
|
|
174
|
+
return {
|
|
175
|
+
user: typeof doc.user === 'string' ? doc.user : '',
|
|
176
|
+
clientId: typeof doc.clientId === 'string' ? doc.clientId : '',
|
|
177
|
+
refreshToken: doc.refreshToken,
|
|
178
|
+
accessToken: typeof doc.accessToken === 'string' ? doc.accessToken : '',
|
|
179
|
+
expiresAt: typeof doc.expiresAt === 'number' && Number.isFinite(doc.expiresAt) ? doc.expiresAt : 0,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/** Persist the store. Owner-only where the platform honours the mode; utf8, no BOM. */
|
|
183
|
+
export function writeTokenStore(store) {
|
|
184
|
+
const file = oauth2TokenFile();
|
|
185
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
186
|
+
writeFileSync(file, JSON.stringify(store, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
187
|
+
}
|
|
188
|
+
/** Drop one account's tokens (a dead refresh token, or a mailbox that moved). */
|
|
189
|
+
export function clearTokenFor(name) {
|
|
190
|
+
const store = readTokenStore();
|
|
191
|
+
if (!Object.prototype.hasOwnProperty.call(store.accounts, name))
|
|
192
|
+
return false;
|
|
193
|
+
delete store.accounts[name];
|
|
194
|
+
writeTokenStore(store);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* What the settings card shows for one account: logged-in wins over a flow
|
|
199
|
+
* that is merely in progress, and an expired flow is not「pending」any more.
|
|
200
|
+
*
|
|
201
|
+
* `configuredUser` is the address the account is configured with right now.
|
|
202
|
+
* A token issued for a *different* mailbox is not a login for this account —
|
|
203
|
+
* reporting it as one would show 「已登录」 on a card whose tools all fail, so
|
|
204
|
+
* the verdict is「none」and the user is sent through the flow again. An empty
|
|
205
|
+
* configured address cannot disagree with anything and keeps the token.
|
|
206
|
+
*/
|
|
207
|
+
export function oauth2StateOf(name, configuredUser, configuredClientId) {
|
|
208
|
+
const entry = readTokenStore().accounts[name];
|
|
209
|
+
if (entry !== undefined) {
|
|
210
|
+
const wanted = (configuredUser ?? '').trim();
|
|
211
|
+
const hasToken = entry.user !== '' && wanted !== '' && entry.user.toLowerCase() !== wanted.toLowerCase();
|
|
212
|
+
const clientMatches = configuredClientId === undefined || entry.clientId === clientIdOf({ clientId: configuredClientId, user: configuredUser ?? '' });
|
|
213
|
+
if (!hasToken && clientMatches)
|
|
214
|
+
return { state: 'logged-in', ...(entry.user !== '' ? { user: entry.user } : {}) };
|
|
215
|
+
}
|
|
216
|
+
const flow = pending.get(name);
|
|
217
|
+
if (flow !== undefined) {
|
|
218
|
+
if (Date.now() < flow.expiresAt)
|
|
219
|
+
return { state: 'pending' };
|
|
220
|
+
pending.delete(name);
|
|
221
|
+
}
|
|
222
|
+
return { state: 'none' };
|
|
223
|
+
}
|
|
224
|
+
export function isDeviceFlowPending(name) {
|
|
225
|
+
return pending.get(name) !== undefined;
|
|
226
|
+
}
|
|
227
|
+
export function clientIdOf(cfg) {
|
|
228
|
+
return (cfg.clientId ?? '').trim() || OUTLOOK_OAUTH2_CLIENT_ID;
|
|
229
|
+
}
|
|
230
|
+
/** POST one form body to the authority, always with a timeout. */
|
|
231
|
+
async function postForm(url, params) {
|
|
232
|
+
let response;
|
|
233
|
+
try {
|
|
234
|
+
response = await fetch(url, {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
237
|
+
body: new URLSearchParams(params).toString(),
|
|
238
|
+
signal: AbortSignal.timeout(OAUTH2_REQUEST_TIMEOUT_MS),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
243
|
+
throw new OAuth2Error('无法连接微软登录服务(login.microsoftonline.com):' + raw);
|
|
244
|
+
}
|
|
245
|
+
let payload;
|
|
246
|
+
try {
|
|
247
|
+
payload = await response.json();
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
payload = undefined;
|
|
251
|
+
}
|
|
252
|
+
return { status: response.status, payload };
|
|
253
|
+
}
|
|
254
|
+
function expiresInMs(payload) {
|
|
255
|
+
const seconds = typeof payload.expires_in === 'number' && Number.isFinite(payload.expires_in) ? payload.expires_in : 0;
|
|
256
|
+
return Math.max(0, Math.trunc(seconds)) * 1000;
|
|
257
|
+
}
|
|
258
|
+
function storeToken(name, cfg, payload, fallbackRefresh) {
|
|
259
|
+
const accessToken = typeof payload.access_token === 'string' ? payload.access_token : '';
|
|
260
|
+
const refreshToken = typeof payload.refresh_token === 'string' && payload.refresh_token !== ''
|
|
261
|
+
? payload.refresh_token
|
|
262
|
+
: fallbackRefresh;
|
|
263
|
+
if (accessToken === '' || refreshToken === '') {
|
|
264
|
+
throw new OAuth2Error('登录响应里没有可用的令牌,请重新发起登录');
|
|
265
|
+
}
|
|
266
|
+
const store = readTokenStore();
|
|
267
|
+
store.accounts[name] = {
|
|
268
|
+
user: (cfg.user ?? '').trim(),
|
|
269
|
+
clientId: clientIdOf(cfg),
|
|
270
|
+
refreshToken,
|
|
271
|
+
accessToken,
|
|
272
|
+
expiresAt: Date.now() + expiresInMs(payload),
|
|
273
|
+
};
|
|
274
|
+
writeTokenStore(store);
|
|
275
|
+
return accessToken;
|
|
276
|
+
}
|
|
277
|
+
// --- device-code flow ---------------------------------------------------------
|
|
278
|
+
/**
|
|
279
|
+
* Step 1: ask for a device code. One flow per account is kept in memory; two
|
|
280
|
+
* concurrent calls share the same request instead of creating two codes.
|
|
281
|
+
*/
|
|
282
|
+
export async function startDeviceFlow(name, cfg) {
|
|
283
|
+
const running = starting.get(name);
|
|
284
|
+
if (running !== undefined)
|
|
285
|
+
return await running;
|
|
286
|
+
const task = (async () => {
|
|
287
|
+
const clientId = clientIdOf(cfg);
|
|
288
|
+
// Refuse locally rather than letting the authority answer AADSTS700011
|
|
289
|
+
// (「application not found」), which tells the user nothing about the one
|
|
290
|
+
// field they have to fill in.
|
|
291
|
+
if (clientId === '')
|
|
292
|
+
throw new OAuth2Error(NO_CLIENT_ID_MESSAGE);
|
|
293
|
+
const { status, payload } = await postForm(DEVICE_CODE_URL, { client_id: clientId, scope: OAUTH2_SCOPE_TEXT });
|
|
294
|
+
const doc = (payload !== null && typeof payload === 'object' ? payload : {});
|
|
295
|
+
if (status !== 200 || typeof doc.device_code !== 'string' || typeof doc.user_code !== 'string') {
|
|
296
|
+
throw new OAuth2Error(classifyOAuthFailure(payload, status).message);
|
|
297
|
+
}
|
|
298
|
+
const url = typeof doc.verification_uri === 'string' && doc.verification_uri !== ''
|
|
299
|
+
? doc.verification_uri
|
|
300
|
+
: (typeof doc.verification_uri_complete === 'string' ? doc.verification_uri_complete : '');
|
|
301
|
+
if (url === '')
|
|
302
|
+
throw new OAuth2Error('微软未返回登录网址,请稍后重试');
|
|
303
|
+
const expiresIn = typeof doc.expires_in === 'number' ? Math.max(0, Math.trunc(doc.expires_in)) : 900;
|
|
304
|
+
const flow = {
|
|
305
|
+
deviceCode: doc.device_code,
|
|
306
|
+
userCode: doc.user_code,
|
|
307
|
+
verificationUri: url,
|
|
308
|
+
interval: clampInt(doc.interval, 5, 1, 60),
|
|
309
|
+
expiresAt: Date.now() + expiresIn * 1000,
|
|
310
|
+
clientId,
|
|
311
|
+
user: (cfg.user ?? '').trim(),
|
|
312
|
+
};
|
|
313
|
+
// A fresh code replaces whatever was pending: the old one is dead anyway.
|
|
314
|
+
pending.set(name, flow);
|
|
315
|
+
return { url, code: flow.userCode, interval: flow.interval, expiresIn };
|
|
316
|
+
})();
|
|
317
|
+
starting.set(name, task);
|
|
318
|
+
try {
|
|
319
|
+
return await task;
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
starting.delete(name);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Step 2: one poll. `pending` is the ordinary answer until the user finishes in
|
|
327
|
+
* the browser; on success the token is persisted before this returns.
|
|
328
|
+
*/
|
|
329
|
+
export async function pollDeviceFlow(name) {
|
|
330
|
+
const flow = pending.get(name);
|
|
331
|
+
if (flow === undefined)
|
|
332
|
+
throw new OAuth2Error('尚未发起设备码登录:请先在设置页点击「登录」');
|
|
333
|
+
if (Date.now() >= flow.expiresAt) {
|
|
334
|
+
pending.delete(name);
|
|
335
|
+
throw new OAuth2Error('设备码超时,请重新发起登录');
|
|
336
|
+
}
|
|
337
|
+
const { status, payload } = await postForm(TOKEN_URL, {
|
|
338
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
339
|
+
client_id: flow.clientId,
|
|
340
|
+
device_code: flow.deviceCode,
|
|
341
|
+
});
|
|
342
|
+
const doc = (payload !== null && typeof payload === 'object' ? payload : {});
|
|
343
|
+
if (status === 200 && typeof doc.access_token === 'string') {
|
|
344
|
+
pending.delete(name);
|
|
345
|
+
storeToken(name, { user: flow.user, clientId: flow.clientId }, doc, '');
|
|
346
|
+
return { status: 'ok', user: flow.user };
|
|
347
|
+
}
|
|
348
|
+
const verdict = classifyOAuthFailure(payload, status);
|
|
349
|
+
if (verdict.pending) {
|
|
350
|
+
if (verdict.slowDown)
|
|
351
|
+
flow.interval += 5;
|
|
352
|
+
return { status: 'pending' };
|
|
353
|
+
}
|
|
354
|
+
pending.delete(name);
|
|
355
|
+
throw new OAuth2Error(verdict.message);
|
|
356
|
+
}
|
|
357
|
+
// --- access tokens -------------------------------------------------------------
|
|
358
|
+
/**
|
|
359
|
+
* A usable access token for one account: the cached one while more than two
|
|
360
|
+
* minutes of its life are left, otherwise a refresh. Concurrent callers share a
|
|
361
|
+
* single refresh request, so a burst of tool calls never mints several tokens
|
|
362
|
+
* (Microsoft rotates the refresh token, and losing that race logs the user out).
|
|
363
|
+
*
|
|
364
|
+
* `force` re-mints even a token that still looks fresh: the only way to tell a
|
|
365
|
+
* revoked token from a network failure after a server-side rejection.
|
|
366
|
+
*/
|
|
367
|
+
export async function getFreshAccessToken(name, cfg, options = {}) {
|
|
368
|
+
const entry = readTokenStore().accounts[name];
|
|
369
|
+
if (entry === undefined)
|
|
370
|
+
throw new OAuth2Error(NOT_LOGGED_IN_MESSAGE);
|
|
371
|
+
const wanted = (cfg.user ?? '').trim();
|
|
372
|
+
const clientId = clientIdOf(cfg);
|
|
373
|
+
if (clientId === '')
|
|
374
|
+
throw new OAuth2Error(NO_CLIENT_ID_MESSAGE);
|
|
375
|
+
if (entry.clientId !== clientId) {
|
|
376
|
+
throw new OAuth2Error('尚未登录:clientId 已更改,请先在设置页重新完成设备码登录');
|
|
377
|
+
}
|
|
378
|
+
if (entry.user !== '' && wanted !== '' && entry.user.toLowerCase() !== wanted.toLowerCase()) {
|
|
379
|
+
throw new OAuth2Error('尚未登录:账号地址已改为 ' + wanted + ',请先在设置页完成设备码登录');
|
|
380
|
+
}
|
|
381
|
+
if (options.force !== true && entry.accessToken !== '' && entry.expiresAt - Date.now() > ACCESS_TOKEN_MARGIN_MS) {
|
|
382
|
+
return entry.accessToken;
|
|
383
|
+
}
|
|
384
|
+
return await refreshAccessToken(name, cfg, entry);
|
|
385
|
+
}
|
|
386
|
+
/** One refresh request per account at a time. */
|
|
387
|
+
function refreshAccessToken(name, cfg, entry) {
|
|
388
|
+
const running = refreshing.get(name);
|
|
389
|
+
if (running !== undefined)
|
|
390
|
+
return running;
|
|
391
|
+
const task = (async () => {
|
|
392
|
+
const { status, payload } = await postForm(TOKEN_URL, {
|
|
393
|
+
grant_type: 'refresh_token',
|
|
394
|
+
client_id: clientIdOf(cfg),
|
|
395
|
+
refresh_token: entry.refreshToken,
|
|
396
|
+
scope: OAUTH2_SCOPE_TEXT,
|
|
397
|
+
});
|
|
398
|
+
const doc = (payload !== null && typeof payload === 'object' ? payload : {});
|
|
399
|
+
if (status === 200 && typeof doc.access_token === 'string') {
|
|
400
|
+
return storeToken(name, cfg, doc, entry.refreshToken);
|
|
401
|
+
}
|
|
402
|
+
const verdict = classifyOAuthFailure(payload, status);
|
|
403
|
+
// Only a dead refresh token is dropped. A timeout or a 5xx must leave the
|
|
404
|
+
// stored token alone: it may well still be good, and losing it would force
|
|
405
|
+
// the user through a browser login for nothing.
|
|
406
|
+
if (verdict.clearToken) {
|
|
407
|
+
try {
|
|
408
|
+
clearTokenFor(name);
|
|
409
|
+
}
|
|
410
|
+
catch { /* the message below is what matters */ }
|
|
411
|
+
}
|
|
412
|
+
throw new OAuth2Error(verdict.message);
|
|
413
|
+
})();
|
|
414
|
+
const tracked = task.then(token => { refreshing.delete(name); return token; }, error => { refreshing.delete(name); throw error; });
|
|
415
|
+
refreshing.set(name, tracked);
|
|
416
|
+
return tracked;
|
|
417
|
+
}
|
package/lib/runtime.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Live settings, account-pool ownership, and independent tool/web watch cursors. */
|
|
2
|
-
import { clampInt, resolveEmailSettings } from './config.js';
|
|
2
|
+
import { clampInt, presetNamesIn, resolveEmailSettings } from './config.js';
|
|
3
3
|
import { EmailPool, messageOf } from './mail-client.js';
|
|
4
4
|
import { EmailSettingsSchema, SETTINGS_NAMESPACE, toEmailConfig, toSettingsBase, validateSettingsValue } from './settings.js';
|
|
5
5
|
function fingerprintSettings(settings) {
|
|
@@ -21,13 +21,26 @@ export function createEmailRuntime(ctx, config, createPool = settings => new Ema
|
|
|
21
21
|
const settingsScope = ctx.settings.register(SETTINGS_NAMESPACE, EmailSettingsSchema, {
|
|
22
22
|
base: toSettingsBase(config),
|
|
23
23
|
applies: 'live',
|
|
24
|
-
|
|
24
|
+
// The provider dropdown offers the custom preset names beside the built-ins,
|
|
25
|
+
// so validation must accept whatever the table in effect defines.
|
|
26
|
+
validate: value => validateSettingsValue(value, presetNamesIn(value?.serverPresets ?? config.serverPresets)),
|
|
25
27
|
});
|
|
26
28
|
const getSettingsValue = () => settingsScope.get();
|
|
27
29
|
const getEffectiveSettings = () => {
|
|
28
30
|
// Form defaults must not overwrite row settings or provider presets.
|
|
29
31
|
const descriptor = (ctx.settings.describe?.() ?? []).find(row => row.ns === SETTINGS_NAMESPACE);
|
|
30
|
-
|
|
32
|
+
const value = getSettingsValue();
|
|
33
|
+
// serverPresets is a *lookup source* for provider ids, not part of the
|
|
34
|
+
// resolved config: it is handed to resolution here and never stored on the
|
|
35
|
+
// result, so editing a preset no account references cannot change the pool
|
|
36
|
+
// fingerprint. toEmailConfig deliberately drops the field, so it is re-added
|
|
37
|
+
// from the scope value — which already merges the row's text with the user's.
|
|
38
|
+
const presets = value.serverPresets;
|
|
39
|
+
return resolveEmailSettings({
|
|
40
|
+
...config,
|
|
41
|
+
...toEmailConfig(value, descriptor?.user),
|
|
42
|
+
...(typeof presets === 'string' ? { serverPresets: presets } : {}),
|
|
43
|
+
});
|
|
31
44
|
};
|
|
32
45
|
let pool = null;
|
|
33
46
|
let poolFingerprint = '';
|
package/lib/settings.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export declare const EmailSettingsSchema: z<Schemastery.ObjectS<{
|
|
|
16
16
|
maxBodyChars: z<number, number>;
|
|
17
17
|
downloadDir: z<string, string>;
|
|
18
18
|
accountsYaml: z<string, string>;
|
|
19
|
+
serverPresets: z<string, string>;
|
|
19
20
|
imap: z<Schemastery.ObjectS<{
|
|
20
21
|
host: z<string, string>;
|
|
21
22
|
port: z<number, number>;
|
|
@@ -43,6 +44,7 @@ export declare const EmailSettingsSchema: z<Schemastery.ObjectS<{
|
|
|
43
44
|
maxBodyChars: z<number, number>;
|
|
44
45
|
downloadDir: z<string, string>;
|
|
45
46
|
accountsYaml: z<string, string>;
|
|
47
|
+
serverPresets: z<string, string>;
|
|
46
48
|
imap: z<Schemastery.ObjectS<{
|
|
47
49
|
host: z<string, string>;
|
|
48
50
|
port: z<number, number>;
|
|
@@ -71,6 +73,7 @@ export interface EmailSettingsValue {
|
|
|
71
73
|
maxBodyChars: number;
|
|
72
74
|
downloadDir: string;
|
|
73
75
|
accountsYaml: string;
|
|
76
|
+
serverPresets?: string;
|
|
74
77
|
imap: {
|
|
75
78
|
host: string;
|
|
76
79
|
port: number;
|
|
@@ -91,11 +94,29 @@ export declare function toSettingsBase(config: EmailConfig): Partial<EmailSettin
|
|
|
91
94
|
* are projected, so schema defaults never shadow the row config or the
|
|
92
95
|
* provider presets (choosing outlook must NOT force smtp port 465 over the
|
|
93
96
|
* preset's 587). Pass `null` to project every field (draft paths).
|
|
97
|
+
*
|
|
98
|
+
* The value may also be *partial*: the settings page posts a draft, and the card
|
|
99
|
+
* editor posts its own control bundle, from which JSON.stringify drops every key
|
|
100
|
+
* it does not model — `provider` above all. A field the draft does not carry
|
|
101
|
+
* means 「未设置」 and must be left out entirely: assigning `out.provider =
|
|
102
|
+
* undefined` is NOT the same as omitting it, because an own key holding undefined
|
|
103
|
+
* still wins in `{ ...rowConfig, ...toEmailConfig(value, null) }` and would erase
|
|
104
|
+
* the row's provider (that produced 「未知的邮件服务商 undefined」 on a card whose
|
|
105
|
+
* provider was plainly selected). Same normalization as toSettingsBase.
|
|
94
106
|
*/
|
|
95
107
|
export declare function toEmailConfig(value: EmailSettingsValue, user?: Partial<EmailSettingsValue> | null): EmailConfig;
|
|
96
108
|
/**
|
|
97
109
|
* Gentle write-path validation: structural mistakes fail loudly, but an
|
|
98
110
|
* incomplete account is allowed (tools report the actionable hint at call
|
|
99
111
|
* time, so an unconfigured install never breaks boot).
|
|
112
|
+
*
|
|
113
|
+
* The value may be partial — the web route validates whatever the page posted,
|
|
114
|
+
* and the card editor's own POST never carries the form fields. 「Missing」 is
|
|
115
|
+
* 「未设置」 for every one of them, exactly as toEmailConfig projects them, so a
|
|
116
|
+
* partial draft is validated only for the fields it actually has.
|
|
117
|
+
*
|
|
118
|
+
* `extraProviders` are the custom preset names in effect: the settings page's
|
|
119
|
+
* provider dropdown offers them beside the 8 built-ins, so a value naming one
|
|
120
|
+
* is a legal choice, not an unknown provider.
|
|
100
121
|
*/
|
|
101
|
-
export declare function validateSettingsValue(value: EmailSettingsValue): void;
|
|
122
|
+
export declare function validateSettingsValue(value: EmailSettingsValue, extraProviders?: readonly string[]): void;
|