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/lib/web.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { isMap, parseDocument } from 'yaml';
4
5
  import { SETTINGS_NAMESPACE, toEmailConfig, validateSettingsValue } from './settings.js';
5
- import { resolveEmailSettings } from './config.js';
6
- import { EmailPool, messageOf } from './mail-client.js';
6
+ import { isOAuth2Account, parseAccountsYaml, parseServerPresets, presetNamesIn, PROVIDER_PRESETS, resolveEmailSettings, serializeAccountsYaml, } from './config.js';
7
+ import { clearTokenFor, getFreshAccessToken, oauth2StateOf, pollDeviceFlow, startDeviceFlow, } from './oauth2.js';
8
+ import { EmailPool, messageOf, redactCredentials } from './mail-client.js';
7
9
  /** Same-origin route the browser settings section talks to. */
8
10
  export const SETTINGS_ROUTE = '/_dsh/dsh-email/settings';
9
11
  /** Same-origin route serving the whale-girl courier image to the widget. */
@@ -15,6 +17,529 @@ const SKIN_PACKAGES = [
15
17
  const WHALE_CREDIT = '鲸鱼娘:一创 上善(pixiv 62155430)· 二创 Small-tailqwq / dsh-deep-whale · CC BY-NC-SA 4.0(非商业)';
16
18
  /** Credit shown with the bundled fallback artwork (community fan character). */
17
19
  const FALLBACK_CREDIT = '鲸鱼娘:社区同人形象,版权归原作者,仅供个人非商业使用;如有异议请提 Issue 移除';
20
+ /** Endpoint defaults for a card whose account (and preset) names no host yet. */
21
+ const IMAP_FALLBACK = { host: '', port: 993, secure: true };
22
+ const SMTP_FALLBACK = { host: '', port: 465, secure: true };
23
+ /**
24
+ * One endpoint for the card, resolved from the provider preset: a preset value
25
+ * wins where it has one, and a placeholder closes whatever is still missing.
26
+ * Empty is a legitimate value — the card is the draft, not a validated config.
27
+ */
28
+ function endpointOf(preset, fallback) {
29
+ const host = typeof preset?.host === 'string' ? preset.host : fallback.host;
30
+ const port = typeof preset?.port === 'number' ? preset.port : fallback.port;
31
+ const secure = typeof preset?.secure === 'boolean' ? preset.secure : fallback.secure;
32
+ return { host, port, secure };
33
+ }
34
+ /**
35
+ * The endpoints a card shows, merged the way config resolution merges them: the
36
+ * account's own `host`/`port`/`secure` win over the preset's, because those are
37
+ * what the pool actually dials. A card that showed only the preset would name a
38
+ * host the plugin never connects to.
39
+ *
40
+ * The account mapping comes straight from user YAML, so every value is narrowed
41
+ * by type before it is merged — spreading a stray string or array would produce
42
+ * index keys rather than endpoints. A field of the wrong type is ignored here
43
+ * exactly as `endpointOf` ignores one in a preset.
44
+ */
45
+ function accountEndpoint(preset, own, fallback) {
46
+ const declared = own !== null && typeof own === 'object' && !Array.isArray(own) ? own : {};
47
+ return endpointOf({
48
+ host: typeof declared.host === 'string' && declared.host !== '' ? declared.host : preset?.host,
49
+ port: typeof declared.port === 'number' ? declared.port : preset?.port,
50
+ secure: typeof declared.secure === 'boolean' ? declared.secure : preset?.secure,
51
+ }, fallback);
52
+ }
53
+ /**
54
+ * Build the account cards for an accounts mapping. Parse-only by contract: a
55
+ * half-filled account is exactly what the user is typing, so it must still
56
+ * render as a card. Nothing here resolves, validates or touches the network —
57
+ * an unknown provider degrades that one card's endpoints, never the list.
58
+ *
59
+ * The endpoints are read from the *preset* only: an account stores a provider
60
+ * id, so a stale hand-written `imap.host` in an existing YAML must not show up
61
+ * in the editor as if it still drove the connection.
62
+ *
63
+ * `tokens` is the OAuth2 login state, looked up once per snapshot rather than
64
+ * per card: the token file is read from disk, and a card is rendered on every
65
+ * keystroke of the settings editor.
66
+ */
67
+ function buildAccountCards(raw, defaultAccount, presets, tokens = () => ({ state: 'none' })) {
68
+ const list = [];
69
+ for (const [name, value] of Object.entries(raw)) {
70
+ const account = value !== null && typeof value === 'object' && !Array.isArray(value)
71
+ ? value
72
+ : {};
73
+ const providerName = typeof account.provider === 'string' && account.provider !== '' ? account.provider : undefined;
74
+ const preset = providerName === undefined ? undefined : providerOf(providerName, presets);
75
+ const imap = accountEndpoint(preset?.imap, account.imap, IMAP_FALLBACK);
76
+ // The same verdict resolution reaches: the provider, or the Exchange Online
77
+ // host a custom preset or a hand-written endpoint points at. An account that
78
+ // pins `authKind` in its YAML outranks the derivation, exactly as it does in
79
+ // config resolution — a card that disagreed with the pool would offer an
80
+ // OAuth2 login for a mailbox that authenticates with a password.
81
+ const pinned = typeof account.authKind === 'string' ? account.authKind.trim().toLowerCase() : '';
82
+ const authKind = pinned === 'oauth2' || pinned === 'password'
83
+ ? pinned
84
+ : isOAuth2Account(providerName, imap.host) ? 'oauth2' : 'password';
85
+ const user = typeof account.user === 'string' ? account.user : '';
86
+ // A public-client id is not a secret, so unlike the password it is handed
87
+ // back for the editor to prefill: an OAuth2 account without one cannot
88
+ // start a device-code login, and the card is where that gets fixed.
89
+ const clientId = typeof account.clientId === 'string' ? account.clientId.trim() : '';
90
+ const oauth = authKind === 'oauth2' ? tokens(name, user) : { state: 'none' };
91
+ list.push({
92
+ name,
93
+ provider: providerName,
94
+ ...(preset?.label !== undefined && preset.label !== '' ? { providerLabel: preset.label } : {}),
95
+ user,
96
+ hasPassword: typeof account.password === 'string' && account.password !== '',
97
+ authKind,
98
+ ...(pinned === 'oauth2' || pinned === 'password' ? { authKindDeclared: pinned } : {}),
99
+ ...(clientId !== '' ? { clientId } : {}),
100
+ oauthState: oauth.state,
101
+ ...(oauth.user !== undefined ? { oauthUser: oauth.user } : {}),
102
+ imap,
103
+ smtp: accountEndpoint(preset?.smtp, account.smtp, SMTP_FALLBACK),
104
+ inboxFolder: typeof account.inboxFolder === 'string' && account.inboxFolder !== '' ? account.inboxFolder : 'INBOX',
105
+ isDefault: name === defaultAccount,
106
+ });
107
+ }
108
+ return list;
109
+ }
110
+ /**
111
+ * Parse the custom preset table. A broken text degrades to "no custom presets"
112
+ * and reports why: the account cards must keep rendering so the user can fix
113
+ * the YAML, and the writer must reach the same verdict as the reader — a name
114
+ * that exists only in an unparseable table is not a name.
115
+ */
116
+ function customPresetsOf(serverPresets) {
117
+ try {
118
+ return { custom: parseServerPresets(serverPresets ?? '') };
119
+ }
120
+ catch (error) {
121
+ return { custom: {}, error: messageOf(error, 'serverPresets 解析失败') };
122
+ }
123
+ }
124
+ /** The 8 built-in presets plus whatever the user defined in serverPresets. */
125
+ function presetsSnapshot(serverPresets) {
126
+ const builtin = {};
127
+ for (const [name, preset] of Object.entries(PROVIDER_PRESETS)) {
128
+ builtin[name] = { imap: { ...preset.imap }, smtp: { ...preset.smtp } };
129
+ }
130
+ return { builtin, ...customPresetsOf(serverPresets) };
131
+ }
132
+ /**
133
+ * Resolve a provider name to its preset, in the same order resolveAccount()
134
+ * uses: the 8 built-ins first, then the custom serverPresets. A name that
135
+ * matches neither (or an inherited Object member) has no preset.
136
+ */
137
+ function providerOf(name, custom) {
138
+ if (Object.prototype.hasOwnProperty.call(PROVIDER_PRESETS, name))
139
+ return PROVIDER_PRESETS[name];
140
+ return Object.prototype.hasOwnProperty.call(custom, name) ? custom[name] : undefined;
141
+ }
142
+ /**
143
+ * Decide which account is the default, mirroring resolveEmailSettings' order
144
+ * (explicit default, then a lone account, then the YAML's own defaultAccount,
145
+ * then an account literally named "default"). Returns the overall error for a
146
+ * mapping that cannot be adjudicated at all.
147
+ */
148
+ function adjudicateAccounts(raw, yamlDefault, rowDefault) {
149
+ const names = Object.keys(raw);
150
+ if (names.length === 0) {
151
+ // No accounts in the YAML: nothing to adjudicate, but a row-level default
152
+ // is still worth reporting.
153
+ const row = (rowDefault ?? '').trim();
154
+ return row === '' ? {} : { defaultAccount: row };
155
+ }
156
+ const explicit = (rowDefault ?? '').trim();
157
+ if (explicit !== '') {
158
+ if (!names.includes(explicit)) {
159
+ return { error: `defaultAccount "${explicit}" 不存在,可用账号:${names.join('、')}` };
160
+ }
161
+ return { defaultAccount: explicit };
162
+ }
163
+ if (names.length === 1)
164
+ return { defaultAccount: names[0] };
165
+ if (yamlDefault !== undefined && names.includes(yamlDefault))
166
+ return { defaultAccount: yamlDefault };
167
+ if (names.includes('default'))
168
+ return { defaultAccount: 'default' };
169
+ return { error: `配置了多个账号(${names.join('、')}),请设置 defaultAccount 指定默认账号` };
170
+ }
171
+ /**
172
+ * True for a document that carries no mapping at all: blank text, or nothing
173
+ * but comments. parseAccountsYaml rejects both, but for the editor "empty" is
174
+ * a legitimate state ("no accounts yet"), not a syntax error.
175
+ */
176
+ function isBlankAccountsText(text) {
177
+ return text.split('\n').every(line => {
178
+ const trimmed = line.trim();
179
+ return trimmed === '' || trimmed.startsWith('#');
180
+ });
181
+ }
182
+ /**
183
+ * Parse accountsYaml text into cards. Never throws: the editor calls this on
184
+ * every keystroke, so a half-typed document is the normal case and comes back
185
+ * as an error field rather than as an HTTP failure.
186
+ *
187
+ * The parsed mapping stays inside this function. It carries plaintext
188
+ * passwords, and the editor only ever consumes the cards — which project
189
+ * `hasPassword` instead of the secret — so no raw account leaves here.
190
+ */
191
+ function readAccountsDraft(text, presets, rowDefault, tokens = () => ({ state: 'none' })) {
192
+ if (isBlankAccountsText(text)) {
193
+ const { defaultAccount, error } = adjudicateAccounts({}, undefined, rowDefault);
194
+ return { ...(defaultAccount !== undefined ? { defaultAccount } : {}), list: [], ...(error !== undefined ? { error } : {}) };
195
+ }
196
+ let raw = {};
197
+ let yamlDefault;
198
+ try {
199
+ const parsed = parseAccountsYaml(text);
200
+ raw = parsed.map;
201
+ yamlDefault = parsed.defaultAccount;
202
+ }
203
+ catch (caught) {
204
+ // No mapping could be read: report it and hand back an empty draft.
205
+ return { list: [], error: messageOf(caught, 'accountsYaml 解析失败') };
206
+ }
207
+ const verdict = adjudicateAccounts(raw, yamlDefault, rowDefault);
208
+ return {
209
+ ...(verdict.defaultAccount !== undefined ? { defaultAccount: verdict.defaultAccount } : {}),
210
+ list: buildAccountCards(raw, verdict.defaultAccount, presets, tokens),
211
+ ...(verdict.error !== undefined ? { error: verdict.error } : {}),
212
+ };
213
+ }
214
+ /**
215
+ * The OAuth2 login state lookup for a card list. The token file is read once
216
+ * and answered from memory afterwards: the editor calls parseAccounts on every
217
+ * keystroke, and one disk read per card would be paid on each of them.
218
+ */
219
+ function tokenLookup() {
220
+ const cache = new Map();
221
+ return (name, user) => {
222
+ const key = name + '\u0000' + user;
223
+ const hit = cache.get(key);
224
+ if (hit !== undefined)
225
+ return hit;
226
+ const fresh = oauth2StateOf(name, user);
227
+ cache.set(key, fresh);
228
+ return fresh;
229
+ };
230
+ }
231
+ /** The raw key a Pair is stored under (an unquoted `163:` parses as a number). */
232
+ function rawKeyOf(pair) {
233
+ const key = pair.key;
234
+ return key !== null && typeof key === 'object' && 'value' in key ? key.value : key;
235
+ }
236
+ /** A Pair's key as text, so lookups match `163:` and `"163":` alike. */
237
+ function keyTextOf(pair) {
238
+ return String(rawKeyOf(pair));
239
+ }
240
+ /**
241
+ * Write one known account field: an empty or absent value deletes the key
242
+ * instead of storing '' (a stored provider: '' resolves as 「provider "" 未知」).
243
+ */
244
+ function writeField(node, key, value) {
245
+ if (value === undefined || value === '') {
246
+ node.delete(key);
247
+ return;
248
+ }
249
+ node.set(key, value);
250
+ }
251
+ /** A scalar/sequence value cannot carry account fields; swap in a map. */
252
+ function ensureMap(doc, node, key) {
253
+ const existing = node.get(key, true);
254
+ if (isMap(existing))
255
+ return existing;
256
+ const fresh = doc.createNode({});
257
+ // Set under the *stored* key, not its text form: `163:` parses as the number
258
+ // 163, and set('163', …) would add a second, quoted key beside it.
259
+ node.set(key, fresh);
260
+ return fresh;
261
+ }
262
+ /**
263
+ * Wash the endpoint fields out of one stored imap/smtp node.
264
+ *
265
+ * An account stores a provider id; host/port/secure are expanded from the
266
+ * preset at resolution time, so a hand-written copy is stale by definition and
267
+ * must not survive an edit. Only those three keys go: an advanced key the card
268
+ * does not model (socketTimeoutMs, connectionTimeoutMs) is not an endpoint and
269
+ * stays exactly where it was. An endpoint map left with nothing in it is
270
+ * removed, so the account keeps only the fields that still mean something.
271
+ */
272
+ function washEndpoint(account, key) {
273
+ const target = account.get(key, true);
274
+ if (!isMap(target))
275
+ return;
276
+ for (const field of ['host', 'port', 'secure'])
277
+ target.delete(field);
278
+ if (target.items.length === 0)
279
+ account.delete(key);
280
+ }
281
+ /**
282
+ * The `provider` value that may be written back to accountsYaml.
283
+ *
284
+ * The account stores a provider *id* and nothing else, so a name the provider
285
+ * table can resolve is exactly what belongs in the document: a built-in preset,
286
+ * or a custom `serverPresets` entry (resolveAccount() consults built-ins first
287
+ * and then the custom table). Anything else — a name the user typed that no
288
+ * preset defines, '' — is dropped, because persisting it would only produce
289
+ * 「账号 "work" 的 provider "corp" 未知」 on the very next connection.
290
+ *
291
+ * `customNames` is the set of preset names in effect for this request. The card
292
+ * editor posts its own control bundle, and an older front end sends no
293
+ * serverPresets at all: the empty set then reproduces the previous
294
+ * "built-ins only" behaviour rather than inventing names.
295
+ *
296
+ * hasOwnProperty, not a plain lookup: `constructor`/`toString` must not be
297
+ * mistaken for preset names through the prototype chain.
298
+ */
299
+ function persistedProvider(provider, customNames) {
300
+ if (typeof provider !== 'string' || provider === '')
301
+ return undefined;
302
+ if (Object.prototype.hasOwnProperty.call(PROVIDER_PRESETS, provider))
303
+ return provider;
304
+ return customNames.has(provider) ? provider : undefined;
305
+ }
306
+ /**
307
+ * Mirror of config.ts' normalizeAccountForYaml for the card shape: provider ''
308
+ * disappears (writing it back resolves as 「provider "" 未知」) and a numeric
309
+ * password becomes a string (YAML would otherwise read back a number).
310
+ *
311
+ * The card's `imap`/`smtp` are deliberately *not* written: an account stores a
312
+ * provider id, and the endpoints are expanded from the preset table at
313
+ * resolution time. Writing them back would freeze a stale copy of a preset the
314
+ * user may later edit.
315
+ *
316
+ * `inheritedPassword` is the value stored in the source YAML for this account.
317
+ * It is used only when the card carries no password at all — see the three-state
318
+ * contract on AccountCardInput: the card omits the field whenever the editor has
319
+ * nothing to say about it, which must leave the stored secret untouched.
320
+ */
321
+ function normalizeCardForYaml(card, customNames, inheritedPassword) {
322
+ const out = {};
323
+ const provider = persistedProvider(card.provider, customNames);
324
+ if (provider !== undefined)
325
+ out.provider = provider;
326
+ if (card.user !== undefined)
327
+ out.user = card.user;
328
+ if (card.password === undefined) {
329
+ // 未提供 = 保持原样,且原样包括「原来是什么类型」:数字密码原样留下数字。
330
+ if (inheritedPassword !== undefined)
331
+ out.password = inheritedPassword;
332
+ }
333
+ else if (card.password === '') {
334
+ // '' = 明确清除:不写 password 键。
335
+ }
336
+ else {
337
+ out.password = typeof card.password === 'number' || typeof card.password === 'boolean'
338
+ ? String(card.password)
339
+ : card.password;
340
+ }
341
+ // Same three-state contract as the in-place writer: a card that does not
342
+ // model the field says nothing about it, '' clears it, a value is written
343
+ // trimmed. Losing this on the degraded path would silently log the account
344
+ // out of its application.
345
+ if (card.clientId !== undefined) {
346
+ const clientId = String(card.clientId).trim();
347
+ if (clientId !== '')
348
+ out.clientId = clientId;
349
+ }
350
+ // '' means「back to automatic」, which is the absence of the key.
351
+ if (card.authKind !== undefined) {
352
+ const authKind = String(card.authKind).trim().toLowerCase();
353
+ if (authKind !== '')
354
+ out.authKind = authKind;
355
+ }
356
+ if (card.inboxFolder !== undefined)
357
+ out.inboxFolder = card.inboxFolder;
358
+ return out;
359
+ }
360
+ /** The `password` stored in the source YAML for one account, if the key is there. */
361
+ function storedPasswordOf(raw, name) {
362
+ const account = raw[name];
363
+ if (account === null || typeof account !== 'object' || Array.isArray(account))
364
+ return { present: false, value: undefined };
365
+ if (!Object.prototype.hasOwnProperty.call(account, 'password'))
366
+ return { present: false, value: undefined };
367
+ return { present: true, value: account.password };
368
+ }
369
+ /**
370
+ * Fallback writer: loses comments but keeps the semantics the cards describe.
371
+ *
372
+ * `source` is the draft being replaced, re-read only to recover passwords the
373
+ * cards do not carry — a card never holds a plaintext password, so without this
374
+ * every degraded save would silently drop the stored secrets. The read is
375
+ * best-effort by nature: this path is reached precisely when the document could
376
+ * not be edited in place, and an unparseable document cannot lend its passwords
377
+ * back. When that happens and a card has nothing to say about its password, the
378
+ * secret is gone and `passwordsDropped` says so.
379
+ */
380
+ function fallbackSerialize(cards, defaultAccount, source, customNames) {
381
+ let stored;
382
+ try {
383
+ stored = parseAccountsYaml(source).map;
384
+ }
385
+ catch {
386
+ stored = undefined;
387
+ }
388
+ let passwordsDropped = false;
389
+ const raw = {};
390
+ for (const card of cards) {
391
+ const name = card.name;
392
+ let inherited;
393
+ if (stored === undefined) {
394
+ // Nothing could be read back: a silent card may be losing a real secret.
395
+ if (card.password === undefined)
396
+ passwordsDropped = true;
397
+ }
398
+ else {
399
+ const { present, value } = storedPasswordOf(stored, name);
400
+ if (present)
401
+ inherited = value;
402
+ }
403
+ raw[name] = normalizeCardForYaml(card, customNames, inherited);
404
+ }
405
+ return {
406
+ accountsYaml: serializeAccountsYaml(raw, defaultAccount),
407
+ ...(passwordsDropped ? { passwordsDropped: true } : {}),
408
+ };
409
+ }
410
+ /**
411
+ * Rewrite the accountsYaml draft from the card list.
412
+ *
413
+ * The card list is the complete desired set: an account key the cards do not
414
+ * name is removed, a name the document does not have is created, and a renamed
415
+ * card therefore deletes the old key and adds the new one. Editing happens on
416
+ * the parsed Document so comments survive and unknown keys (socketTimeoutMs,
417
+ * …) stay exactly where they were.
418
+ */
419
+ function serializeAccountsDraft(source, cards, defaultAccount, customNames) {
420
+ const doc = parseDocument(source);
421
+ // parseDocument never throws — a broken document surfaces as doc.errors, and
422
+ // String(doc) then refuses to run at all. Degrade to the stringify path.
423
+ if (doc.errors.length > 0)
424
+ return { ...fallbackSerialize(cards, defaultAccount, source, customNames), commentsDropped: true };
425
+ let root;
426
+ if (doc.contents === null) {
427
+ root = doc.createNode({});
428
+ doc.contents = root;
429
+ }
430
+ else if (isMap(doc.contents)) {
431
+ root = doc.contents;
432
+ }
433
+ else {
434
+ // A sequence or scalar document cannot carry accounts at all.
435
+ return { ...fallbackSerialize(cards, defaultAccount, source, customNames), commentsDropped: true };
436
+ }
437
+ const desired = new Set(cards.map(card => card.name));
438
+ if (desired.size !== cards.length)
439
+ throw new Error('账号名重复,不能覆盖已有账号');
440
+ // Rename the YAML node before filtering, retaining secrets and advanced keys.
441
+ // originalName may already have been renamed by a preceding auto-save.
442
+ const renameTargets = new Set();
443
+ for (const card of cards) {
444
+ const from = card.originalName;
445
+ const to = card.name;
446
+ if (!from || from === to || !root.has(from))
447
+ continue;
448
+ if (root.has(to) || renameTargets.has(from))
449
+ throw new Error('账号名已存在,不能覆盖已有账号');
450
+ const pair = root.items.find(item => keyTextOf(item) === from);
451
+ if (pair !== undefined) {
452
+ pair.key = doc.createNode(to);
453
+ renameTargets.add(from);
454
+ }
455
+ }
456
+ const seen = new Set();
457
+ for (const pair of [...root.items]) {
458
+ const name = keyTextOf(pair);
459
+ if (name === 'defaultAccount')
460
+ continue;
461
+ // Remove accounts the cards dropped, and collapse a duplicate spelling
462
+ // (`163:` and `"163":`) onto a single key.
463
+ if (!desired.has(name) || seen.has(name)) {
464
+ root.delete(rawKeyOf(pair));
465
+ continue;
466
+ }
467
+ seen.add(name);
468
+ }
469
+ for (const card of cards) {
470
+ const name = card.name;
471
+ const pair = root.items.find(item => keyTextOf(item) === name);
472
+ let account;
473
+ if (pair === undefined) {
474
+ account = doc.createNode({});
475
+ root.set(name, account);
476
+ }
477
+ else {
478
+ // Reuse the stored key node (163 vs "163") so the name keeps its spelling.
479
+ account = ensureMap(doc, root, rawKeyOf(pair));
480
+ }
481
+ // The provider is the account's whole connection identity: a resolvable
482
+ // preset name is written, anything else deletes the key. Endpoints are
483
+ // never written — a stored copy is washed out below instead.
484
+ const previousProvider = account.get('provider');
485
+ const nextProvider = persistedProvider(card.provider, customNames);
486
+ writeField(account, 'provider', nextProvider);
487
+ writeField(account, 'user', card.user);
488
+ // Password is three-state, unlike every other field: the card is never given
489
+ // the plaintext (snapshot exposes hasPassword only), so an omitted password
490
+ // means "the editor has nothing to say" and the stored key must survive
491
+ // untouched — value, position and comment. Only an explicit '' clears it.
492
+ if (card.password === undefined) {
493
+ // 未提供 = 保持原样:什么都不写。
494
+ }
495
+ else if (card.password === '') {
496
+ account.delete('password');
497
+ }
498
+ else {
499
+ // Same coercion as normalizeAccountForYaml: a numeric password must be
500
+ // written as a string, or YAML reads it back as a number.
501
+ account.set('password', typeof card.password === 'number' || typeof card.password === 'boolean'
502
+ ? String(card.password)
503
+ : card.password);
504
+ }
505
+ // clientId is three-state for the same reason: a card that does not model
506
+ // the field — a password account, an older editor — has nothing to say about
507
+ // it, and a value the user typed into the YAML must survive an unrelated
508
+ // save. Only an explicit '' clears it.
509
+ if (card.clientId === undefined) {
510
+ // 未提供 = 保持原样:什么都不写。
511
+ }
512
+ else {
513
+ writeField(account, 'clientId', String(card.clientId).trim());
514
+ }
515
+ // Same three states, and here '' is meaningful rather than merely empty: it
516
+ // takes the pin back off, returning the account to the derivation.
517
+ if (card.authKind !== undefined) {
518
+ writeField(account, 'authKind', String(card.authKind).trim().toLowerCase());
519
+ }
520
+ writeField(account, 'inboxFolder', card.inboxFolder);
521
+ // Endpoints are never written by a card: they come from the preset, or from
522
+ // what the account already stores. They are washed only when the provider
523
+ // actually changed, because that is the one case where the stored copy is
524
+ // stale by definition — it belongs to the previous provider. Washing on
525
+ // every save would repoint a self-hosted account at the preset merely
526
+ // because the user opened this panel.
527
+ if (nextProvider !== undefined && nextProvider !== previousProvider) {
528
+ washEndpoint(account, 'imap');
529
+ washEndpoint(account, 'smtp');
530
+ }
531
+ }
532
+ if (defaultAccount !== '')
533
+ root.set('defaultAccount', defaultAccount);
534
+ else
535
+ root.delete('defaultAccount');
536
+ // No accounts left: '' (never '{}'). resolveEmailSettings decides "is the
537
+ // YAML authoritative" with .trim(), so an empty mapping must stay empty.
538
+ const accountKeys = root.items.filter(pair => keyTextOf(pair) !== 'defaultAccount').length;
539
+ if (accountKeys === 0)
540
+ return { accountsYaml: '' };
541
+ return { accountsYaml: String(doc) };
542
+ }
18
543
  let whaleCache;
19
544
  function pickFromDir(dir) {
20
545
  let names = [];
@@ -69,6 +594,96 @@ function findWhaleAsset() {
69
594
  }
70
595
  return whaleCache;
71
596
  }
597
+ /**
598
+ * The account names a settings value carries, or `undefined` when the document
599
+ * could not be read.
600
+ *
601
+ * Three states on purpose. An empty text really does mean "no accounts", but an
602
+ * unparseable one means "no idea" — and a caller that confused the two would
603
+ * treat every account as deleted and wipe their stored OAuth2 tokens.
604
+ */
605
+ function accountNamesOf(value) {
606
+ const text = typeof value?.accountsYaml === 'string' ? value.accountsYaml : '';
607
+ if (text.trim() === '')
608
+ return new Set();
609
+ try {
610
+ return new Set(Object.keys(parseAccountsYaml(text).map));
611
+ }
612
+ catch {
613
+ return undefined;
614
+ }
615
+ }
616
+ /** Hostnames a browser may legitimately reach this route through. */
617
+ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
618
+ /**
619
+ * Verdict on the `Host` header: `undefined` to proceed, otherwise the reason to
620
+ * refuse.
621
+ *
622
+ * The localhost gate on `socket.remoteAddress` proves where the packets came
623
+ * from, not which name the browser believes it is talking to. A page the user
624
+ * visits can point a domain at 127.0.0.1 (DNS rebinding); that request looks
625
+ * same-origin to the browser, carries no `Origin`, and would make the snapshot
626
+ * readable — and the snapshot carries `accountsYaml`, plaintext 授权码 included.
627
+ * Requiring a localhost `Host` closes it.
628
+ *
629
+ * A request with no `Host` at all did not come from a browser (HTTP/1.0, curl,
630
+ * the test harness), and the remote-address gate still applies to it.
631
+ */
632
+ export function hostVerdict(host) {
633
+ if (typeof host !== 'string' || host.trim() === '')
634
+ return undefined;
635
+ const text = host.trim().toLowerCase();
636
+ // An IPv6 literal arrives bracketed — `[::1]:3080` — where splitting on the
637
+ // colon would leave「[」and refuse a legitimate way to reach the panel.
638
+ const name = text.startsWith('[') ? text.slice(0, text.indexOf(']') + 1) : text.split(':')[0];
639
+ return LOCAL_HOSTNAMES.has(name) ? undefined : `host "${host}" is not a localhost name`;
640
+ }
641
+ /**
642
+ * Verdict on a state-changing POST: `undefined` to proceed, otherwise the status
643
+ * and reason to refuse.
644
+ *
645
+ * Cross-origin writes are the hole the remote-address gate cannot see: a browser
646
+ * page may POST here as a「simple request」(text/plain, no preflight) and change
647
+ * settings or trigger a dial. Three independent checks close it:
648
+ *
649
+ * - `application/json` is not a simple-request content type, so a cross-origin
650
+ * caller is forced into a preflight, which this route never answers with
651
+ * `Access-Control-Allow-Origin`.
652
+ * - `Origin`, when it names an http(s) page, must be a localhost origin. Other
653
+ * schemes are left to the next check: the host may load its UI through a
654
+ * custom protocol, and a hostile page cannot produce one.
655
+ * - `Sec-Fetch-Site`, when present, must be `same-origin` (or `none`, a
656
+ * user-initiated navigation with no referrer). This is what catches an opaque
657
+ * `Origin: null` from a sandboxed iframe.
658
+ *
659
+ * Headers a non-browser client omits are not fabricatable by page script, so
660
+ * their absence is allowed rather than treated as a rejection.
661
+ */
662
+ export function postVerdict(headers) {
663
+ const contentType = String(headers['content-type'] ?? '');
664
+ if (!contentType.toLowerCase().includes('application/json')) {
665
+ return { status: 415, message: 'dsh-email settings route accepts application/json only' };
666
+ }
667
+ const origin = headers.origin;
668
+ if (typeof origin === 'string' && origin.trim() !== '') {
669
+ let url;
670
+ try {
671
+ url = new URL(origin);
672
+ }
673
+ catch {
674
+ url = undefined;
675
+ }
676
+ if (url !== undefined && (url.protocol === 'http:' || url.protocol === 'https:')
677
+ && !LOCAL_HOSTNAMES.has(url.hostname.toLowerCase())) {
678
+ return { status: 403, message: `origin "${origin}" is not allowed to write dsh-email settings` };
679
+ }
680
+ }
681
+ const site = headers['sec-fetch-site'];
682
+ if (typeof site === 'string' && site !== '' && site !== 'same-origin' && site !== 'none') {
683
+ return { status: 403, message: `a ${site} request may not write dsh-email settings` };
684
+ }
685
+ return undefined;
686
+ }
72
687
  /**
73
688
  * Browser-facing backend: snapshot the settings namespace, save it with
74
689
  * optimistic concurrency, and test a draft account over a live IMAP login.
@@ -90,12 +705,22 @@ export class EmailSettingsBackend {
90
705
  }
91
706
  /** Effective config for the stored value (row + user-set fields only). */
92
707
  effectiveStored() {
93
- return { ...this.rowConfig, ...toEmailConfig(this.scope.get(), this.userSection()) };
708
+ const stored = this.scope.get();
709
+ const merged = { ...this.rowConfig, ...toEmailConfig(stored, this.userSection()) };
710
+ // toEmailConfig drops serverPresets — it must never enter the fingerprint —
711
+ // but resolution needs it as a provider lookup source. The scope value
712
+ // already resolves row-vs-user precedence for it, so it is re-added as-is.
713
+ return { ...merged, ...(typeof stored?.serverPresets === 'string' ? { serverPresets: stored.serverPresets } : {}) };
94
714
  }
95
715
  async snapshot() {
96
716
  const descriptor = (this.ctx.settings.describe?.() ?? []).find((row) => row.ns === SETTINGS_NAMESPACE);
97
717
  const value = this.scope.get();
98
718
  const whale = findWhaleAsset();
719
+ const presets = presetsSnapshot(value.serverPresets);
720
+ // The cards describe the *effective* accountsYaml — the same text the
721
+ // advanced editor shows, and the same source the accounts field reads.
722
+ const effective = this.effectiveStored();
723
+ const draft = readAccountsDraft(effective.accountsYaml ?? '', presets.custom, effective.defaultAccount, tokenLookup());
99
724
  return {
100
725
  settings: {
101
726
  value,
@@ -104,6 +729,12 @@ export class EmailSettingsBackend {
104
729
  },
105
730
  writable: this.ctx.settings.writable !== false,
106
731
  accounts: [...(this.effectiveAccounts().keys())],
732
+ accountsDetail: {
733
+ ...(draft.defaultAccount !== undefined ? { defaultAccount: draft.defaultAccount } : {}),
734
+ list: draft.list,
735
+ ...(draft.error !== undefined ? { error: draft.error } : {}),
736
+ },
737
+ presets,
107
738
  whale: whale === null
108
739
  ? { url: '', skin: false, credit: '' }
109
740
  : { url: WHALE_ASSET_ROUTE, skin: whale.skin, credit: whale.credit },
@@ -120,27 +751,78 @@ export class EmailSettingsBackend {
120
751
  async save(value, expectedRevision) {
121
752
  if (this.ctx.settings.writable === false)
122
753
  throw new Error('settings provider is read-only');
123
- validateSettingsValue(value);
754
+ // The provider dropdown offers the custom preset names, so a value naming
755
+ // one of them is a legal choice rather than an unknown provider.
756
+ validateSettingsValue(value, presetNamesIn(value?.serverPresets ?? this.scope.get()?.serverPresets));
757
+ const before = accountNamesOf(this.scope.get());
124
758
  await this.ctx.settings.replace(SETTINGS_NAMESPACE, value, expectedRevision);
759
+ // A deleted account must not leave its refresh token behind: the store is
760
+ // keyed by account name, so the credential of a mailbox that is no longer
761
+ // configured would sit on disk, and a later account reusing that name would
762
+ // inherit it. Only once the write has committed — a draft, or a 409, must
763
+ // not cost anybody their login. And never on an unreadable document: not
764
+ // knowing the new account list is not the same as knowing it is empty.
765
+ const after = accountNamesOf(value);
766
+ if (before !== undefined && after !== undefined) {
767
+ for (const name of before) {
768
+ if (!after.has(name))
769
+ clearTokenFor(name);
770
+ }
771
+ }
125
772
  return this.snapshot();
126
773
  }
127
- async test(value) {
128
- validateSettingsValue(value);
774
+ /**
775
+ * Test one account (by name, defaulting to the draft's default account) over
776
+ * a live IMAP login. Returns the endpoint it dialled so the panel can show
777
+ * what was actually tried — including on failure.
778
+ */
779
+ async test(value, accountName) {
780
+ validateSettingsValue(value, presetNamesIn(value?.serverPresets ?? this.scope.get()?.serverPresets));
129
781
  // null projects the complete draft: test the form as the user typed it.
130
- const settings = resolveEmailSettings({ ...this.rowConfig, ...toEmailConfig(value, null) });
782
+ // serverPresets rides along as the provider lookup source, exactly as it
783
+ // does for the stored settings (it never enters the resolved fingerprint).
784
+ const draft = toEmailConfig(value, null);
785
+ const presets = value?.serverPresets ?? this.scope.get()?.serverPresets;
786
+ const settings = resolveEmailSettings({
787
+ ...this.rowConfig,
788
+ ...draft,
789
+ ...(typeof presets === 'string' ? { serverPresets: presets } : {}),
790
+ });
791
+ const requested = typeof accountName === 'string' && accountName.trim() !== '' ? accountName.trim() : '';
792
+ const available = [...settings.accounts.keys()];
793
+ const name = requested !== '' ? requested : settings.defaultAccount;
794
+ const cfg = settings.accounts.get(name);
795
+ if (cfg === undefined) {
796
+ throw new Error(`未知账号 "${name}",可用:${available.join('、')}`);
797
+ }
798
+ const target = { account: name, imapHost: cfg.imap.host, imapPort: cfg.imap.port };
799
+ // An OAuth2 account has no password to check: without a token there is
800
+ // nothing to dial with, and a failed dial would only say so less clearly.
801
+ if (cfg.authKind === 'oauth2') {
802
+ try {
803
+ await getFreshAccessToken(name, cfg);
804
+ }
805
+ catch (error) {
806
+ throw new Error(messageOf(error, '尚未登录:请先在设置页完成设备码登录'));
807
+ }
808
+ }
131
809
  const pool = new EmailPool(settings);
132
810
  try {
133
811
  const started = Date.now();
134
- await pool.withImap(settings.defaultAccount, null, async () => 'connected');
135
- return { ok: true, ms: Date.now() - started };
812
+ await pool.withImap(name, null, async () => 'connected');
813
+ return { ok: true, ms: Date.now() - started, ...target };
136
814
  }
137
815
  catch (error) {
138
816
  // imapflow reports failed LOGIN as a bare "Command failed"; surface an
139
- // actionable hint instead of the opaque message.
140
- const raw = messageOf(error, 'unknown error');
817
+ // actionable hint instead of the opaque message. The server's own text is
818
+ // redacted first: a refused authentication string is echoed verbatim by
819
+ // many servers, and for XOAUTH2 that blob carries the access token.
820
+ const raw = redactCredentials(messageOf(error, 'unknown error'));
141
821
  const lower = raw.toLowerCase();
142
822
  if (lower.includes('command failed') || lower.includes('authentication') || lower.includes('login')) {
143
- throw new Error('邮箱登录失败:请检查邮箱地址与授权码(' + raw + ')');
823
+ throw new Error(cfg.authKind === 'oauth2'
824
+ ? '邮箱登录失败:请在设置页重新完成设备码登录(' + raw + ')'
825
+ : '邮箱登录失败:请检查邮箱地址与授权码(' + raw + ')');
144
826
  }
145
827
  throw error;
146
828
  }
@@ -148,6 +830,86 @@ export class EmailSettingsBackend {
148
830
  pool.dispose();
149
831
  }
150
832
  }
833
+ /**
834
+ * Resolve one named account of the *stored* settings — the same accounts the
835
+ * tools and the card list see. A login is not a draft operation: the settings
836
+ * page saves the card before it starts one, so the account being logged into
837
+ * is by definition already persisted.
838
+ */
839
+ oauthAccount(name, action) {
840
+ const wanted = typeof name === 'string' ? name.trim() : '';
841
+ if (wanted === '')
842
+ throw new Error(action + ' 需要 account 参数(账号名)');
843
+ let settings;
844
+ try {
845
+ settings = resolveEmailSettings(this.effectiveStored());
846
+ }
847
+ catch (error) {
848
+ throw new Error(messageOf(error, '邮箱账号未配置'));
849
+ }
850
+ const cfg = settings.accounts.get(wanted);
851
+ if (cfg === undefined) {
852
+ throw new Error('未知账号 "' + wanted + '",可用:' + [...settings.accounts.keys()].join('、'));
853
+ }
854
+ if (cfg.authKind !== 'oauth2') {
855
+ // The card only offers the login button on an OAuth2 account; reaching
856
+ // here means the page is stale or the provider was just changed.
857
+ throw new Error('账号 "' + wanted + '" 不需要设备码登录:只有 outlook(Exchange Online)账号使用 OAuth2');
858
+ }
859
+ return { name: wanted, cfg };
860
+ }
861
+ /**
862
+ * Start (or report) the device-code login for one OAuth2 account.
863
+ *
864
+ * An account that already holds a token answers `already` — the card shows
865
+ * 「已登录」and there is no second code to hand out. Otherwise the authority's
866
+ * device code is returned verbatim: url = verification_uri, code = user_code,
867
+ * and both interval and expires_in in seconds, which is the unit the page
868
+ * schedules its polling with.
869
+ */
870
+ async oauthLogin(name) {
871
+ try {
872
+ const { name: account, cfg } = this.oauthAccount(name, 'oauthLogin');
873
+ // Same verdict the card renders: a token belonging to a different
874
+ // address is not a login for this account, so it starts a fresh flow.
875
+ const state = oauth2StateOf(account, cfg.user, cfg.clientId ?? '');
876
+ if (state.state === 'logged-in')
877
+ return { ok: true, status: 'already' };
878
+ const start = await startDeviceFlow(account, cfg);
879
+ return {
880
+ ok: true,
881
+ status: 'pending',
882
+ url: start.url,
883
+ code: start.code,
884
+ interval: start.interval,
885
+ expires_in: start.expiresIn,
886
+ };
887
+ }
888
+ catch (error) {
889
+ return { ok: false, message: messageOf(error, '登录失败:请稍后重试') };
890
+ }
891
+ }
892
+ /**
893
+ * One poll of an in-flight device-code login.
894
+ *
895
+ * `authorization_pending` is the ordinary answer for as long as the user has
896
+ * not finished in the browser, so it is reported as a state rather than an
897
+ * error: only a refused or expired flow comes back as ok:false.
898
+ */
899
+ async oauthPoll(name) {
900
+ try {
901
+ const { name: account } = this.oauthAccount(name, 'oauthPoll');
902
+ const result = await pollDeviceFlow(account);
903
+ if (result.status === 'pending')
904
+ return { ok: true, status: 'pending' };
905
+ return { ok: true, status: 'ok', user: result.user };
906
+ }
907
+ catch (error) {
908
+ // Every failure of a poll is a login failure the user has to see, so it
909
+ // leaves as the flat { ok:false, message } the page renders.
910
+ return { ok: false, message: messageOf(error, '登录失败:请稍后重试') };
911
+ }
912
+ }
151
913
  responseJson(res, status, body) {
152
914
  const bytes = Buffer.from(JSON.stringify(body));
153
915
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
@@ -165,6 +927,28 @@ export class EmailSettingsBackend {
165
927
  this.responseJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-email settings route is localhost-only' } });
166
928
  return;
167
929
  }
930
+ // Localhost is where the packets came from, not which name the browser thinks
931
+ // it reached: a rebound domain resolves to 127.0.0.1 and would otherwise read
932
+ // the snapshot, `accountsYaml` and its plaintext 授权码 included.
933
+ const headers = (req.headers ?? {});
934
+ const host = hostVerdict(headers.host);
935
+ if (host !== undefined) {
936
+ this.responseJson(res, 403, { ok: false, error: { code: 'forbidden', message: host } });
937
+ return;
938
+ }
939
+ if (req.method === 'POST') {
940
+ // A page the user visits can POST here cross-origin as a「simple request」
941
+ // and change settings or trigger a dial; these headers are the part of that
942
+ // a page script cannot forge away.
943
+ const post = postVerdict(headers);
944
+ if (post !== undefined) {
945
+ this.responseJson(res, post.status, {
946
+ ok: false,
947
+ error: { code: post.status === 415 ? 'unsupported-media-type' : 'forbidden', message: post.message },
948
+ });
949
+ return;
950
+ }
951
+ }
168
952
  if (req.method === 'GET') {
169
953
  try {
170
954
  this.responseJson(res, 200, { ok: true, value: await this.snapshot() });
@@ -195,13 +979,23 @@ export class EmailSettingsBackend {
195
979
  return;
196
980
  }
197
981
  try {
198
- if (body?.action === 'save') {
982
+ if (body?.action === 'oauthLogin' || body?.action === 'oauthPoll') {
983
+ // The two login actions answer with the flat object itself, not the
984
+ // { ok, value } envelope the other actions use: their whole meaning is
985
+ // { ok, status }, and a failure is a flat { ok:false, message } rather
986
+ // than the error envelope. The page reads them straight off the body.
987
+ const answer = body.action === 'oauthLogin'
988
+ ? await this.oauthLogin(body.account)
989
+ : await this.oauthPoll(body.account);
990
+ this.responseJson(res, 200, answer);
991
+ }
992
+ else if (body?.action === 'save') {
199
993
  if (!Number.isSafeInteger(body.expectedRevision))
200
994
  throw new Error('expectedRevision must be a non-negative integer');
201
995
  this.responseJson(res, 200, { ok: true, value: await this.save(body.value, body.expectedRevision) });
202
996
  }
203
997
  else if (body?.action === 'test') {
204
- this.responseJson(res, 200, { ok: true, value: await this.test(body.value) });
998
+ this.responseJson(res, 200, { ok: true, value: await this.test(body.value, body.account) });
205
999
  }
206
1000
  else if (body?.action === 'watch') {
207
1001
  if (typeof this.watchImpl !== 'function')
@@ -211,6 +1005,59 @@ export class EmailSettingsBackend {
211
1005
  const limit = Number.isSafeInteger(body.limit) ? body.limit : 5;
212
1006
  this.responseJson(res, 200, { ok: true, value: await this.watchImpl(account, folder, limit, 'web') });
213
1007
  }
1008
+ else if (body?.action === 'parseAccounts') {
1009
+ // Pure and always 200: the editor calls this on every keystroke, so a
1010
+ // half-typed document is the normal case, not an HTTP failure.
1011
+ const text = typeof body.value?.accountsYaml === 'string' ? body.value.accountsYaml : '';
1012
+ const presets = typeof body.serverPresets === 'string'
1013
+ ? customPresetsOf(body.serverPresets).custom
1014
+ : customPresetsOf(this.scope.get().serverPresets).custom;
1015
+ const draft = readAccountsDraft(text, presets, this.rowConfig.defaultAccount, tokenLookup());
1016
+ this.responseJson(res, 200, {
1017
+ ok: true,
1018
+ value: {
1019
+ ok: draft.error === undefined,
1020
+ ...(draft.error !== undefined ? { error: draft.error } : {}),
1021
+ ...(draft.defaultAccount !== undefined ? { defaultAccount: draft.defaultAccount } : {}),
1022
+ list: draft.list,
1023
+ },
1024
+ });
1025
+ }
1026
+ else if (body?.action === 'serializeAccounts') {
1027
+ if (!Array.isArray(body.accounts))
1028
+ throw new Error('accounts 必须是数组');
1029
+ const cards = [];
1030
+ for (const entry of body.accounts) {
1031
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
1032
+ throw new Error('accounts 的每一项都必须是对象');
1033
+ const name = typeof entry.name === 'string' ? entry.name.trim() : '';
1034
+ if (name === '')
1035
+ throw new Error('accounts 的每一项都需要非空的 name');
1036
+ if (name === 'defaultAccount')
1037
+ throw new Error('账号名不能是 defaultAccount(该键保留给默认账号)');
1038
+ cards.push({ ...entry, name });
1039
+ }
1040
+ const source = typeof body.accountsYaml === 'string' ? body.accountsYaml : '';
1041
+ const chosen = typeof body.defaultAccount === 'string' ? body.defaultAccount.trim() : '';
1042
+ // The body may carry the preset table the page is editing: a custom
1043
+ // preset name is only persistable when the table in effect defines it.
1044
+ // An older front end sends none, which reproduces "built-ins only".
1045
+ const bodyPresets = typeof body.serverPresets === 'string'
1046
+ ? customPresetsOf(body.serverPresets).custom
1047
+ : customPresetsOf(this.scope.get().serverPresets).custom;
1048
+ const written = serializeAccountsDraft(source, cards, chosen, new Set(Object.keys(bodyPresets)));
1049
+ this.responseJson(res, 200, {
1050
+ ok: true,
1051
+ value: {
1052
+ accountsYaml: written.accountsYaml,
1053
+ ...(written.commentsDropped === true ? { commentsDropped: true } : {}),
1054
+ // A card that says nothing about its password keeps the stored one;
1055
+ // this flag is the honest signal that it could not be kept because
1056
+ // the source document itself was unreadable.
1057
+ ...(written.passwordsDropped === true ? { passwordsDropped: true } : {}),
1058
+ },
1059
+ });
1060
+ }
214
1061
  else {
215
1062
  this.responseJson(res, 400, { ok: false, error: { code: 'invalid-request', message: 'unsupported action' } });
216
1063
  }