dsh-email 0.10.7 → 0.12.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,585 @@ 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
+ // The display name and the login user are not secrets, so — like clientId —
91
+ // the card hands them back for the editor to prefill. A separate login
92
+ // password is a secret and only ever reported as a boolean.
93
+ const senderName = typeof account.senderName === 'string' ? account.senderName.trim() : '';
94
+ const authUser = typeof account.authUser === 'string' ? account.authUser.trim() : '';
95
+ const oauth = authKind === 'oauth2' ? tokens(name, user) : { state: 'none' };
96
+ list.push({
97
+ name,
98
+ provider: providerName,
99
+ ...(preset?.label !== undefined && preset.label !== '' ? { providerLabel: preset.label } : {}),
100
+ user,
101
+ hasPassword: typeof account.password === 'string' && account.password !== '',
102
+ authKind,
103
+ ...(pinned === 'oauth2' || pinned === 'password' ? { authKindDeclared: pinned } : {}),
104
+ ...(clientId !== '' ? { clientId } : {}),
105
+ ...(senderName !== '' ? { senderName } : {}),
106
+ ...(authUser !== '' ? { authUser } : {}),
107
+ ...(typeof account.authPassword === 'string' && account.authPassword !== '' ? { hasAuthPassword: true } : {}),
108
+ oauthState: oauth.state,
109
+ ...(oauth.user !== undefined ? { oauthUser: oauth.user } : {}),
110
+ imap,
111
+ smtp: accountEndpoint(preset?.smtp, account.smtp, SMTP_FALLBACK),
112
+ inboxFolder: typeof account.inboxFolder === 'string' && account.inboxFolder !== '' ? account.inboxFolder : 'INBOX',
113
+ isDefault: name === defaultAccount,
114
+ });
115
+ }
116
+ return list;
117
+ }
118
+ /**
119
+ * Parse the custom preset table. A broken text degrades to "no custom presets"
120
+ * and reports why: the account cards must keep rendering so the user can fix
121
+ * the YAML, and the writer must reach the same verdict as the reader — a name
122
+ * that exists only in an unparseable table is not a name.
123
+ */
124
+ function customPresetsOf(serverPresets) {
125
+ try {
126
+ return { custom: parseServerPresets(serverPresets ?? '') };
127
+ }
128
+ catch (error) {
129
+ return { custom: {}, error: messageOf(error, 'serverPresets 解析失败') };
130
+ }
131
+ }
132
+ /** The 8 built-in presets plus whatever the user defined in serverPresets. */
133
+ function presetsSnapshot(serverPresets) {
134
+ const builtin = {};
135
+ for (const [name, preset] of Object.entries(PROVIDER_PRESETS)) {
136
+ builtin[name] = { imap: { ...preset.imap }, smtp: { ...preset.smtp } };
137
+ }
138
+ return { builtin, ...customPresetsOf(serverPresets) };
139
+ }
140
+ /**
141
+ * Resolve a provider name to its preset, in the same order resolveAccount()
142
+ * uses: the 8 built-ins first, then the custom serverPresets. A name that
143
+ * matches neither (or an inherited Object member) has no preset.
144
+ */
145
+ function providerOf(name, custom) {
146
+ if (Object.prototype.hasOwnProperty.call(PROVIDER_PRESETS, name))
147
+ return PROVIDER_PRESETS[name];
148
+ return Object.prototype.hasOwnProperty.call(custom, name) ? custom[name] : undefined;
149
+ }
150
+ /**
151
+ * Decide which account is the default, mirroring resolveEmailSettings' order
152
+ * (explicit default, then a lone account, then the YAML's own defaultAccount,
153
+ * then an account literally named "default"). Returns the overall error for a
154
+ * mapping that cannot be adjudicated at all.
155
+ */
156
+ function adjudicateAccounts(raw, yamlDefault, rowDefault) {
157
+ const names = Object.keys(raw);
158
+ if (names.length === 0) {
159
+ // No accounts in the YAML: nothing to adjudicate, but a row-level default
160
+ // is still worth reporting.
161
+ const row = (rowDefault ?? '').trim();
162
+ return row === '' ? {} : { defaultAccount: row };
163
+ }
164
+ const explicit = (rowDefault ?? '').trim();
165
+ if (explicit !== '') {
166
+ if (!names.includes(explicit)) {
167
+ return { error: `defaultAccount "${explicit}" 不存在,可用账号:${names.join('、')}` };
168
+ }
169
+ return { defaultAccount: explicit };
170
+ }
171
+ if (names.length === 1)
172
+ return { defaultAccount: names[0] };
173
+ if (yamlDefault !== undefined && names.includes(yamlDefault))
174
+ return { defaultAccount: yamlDefault };
175
+ if (names.includes('default'))
176
+ return { defaultAccount: 'default' };
177
+ return { error: `配置了多个账号(${names.join('、')}),请设置 defaultAccount 指定默认账号` };
178
+ }
179
+ /**
180
+ * True for a document that carries no mapping at all: blank text, or nothing
181
+ * but comments. parseAccountsYaml rejects both, but for the editor "empty" is
182
+ * a legitimate state ("no accounts yet"), not a syntax error.
183
+ */
184
+ function isBlankAccountsText(text) {
185
+ return text.split('\n').every(line => {
186
+ const trimmed = line.trim();
187
+ return trimmed === '' || trimmed.startsWith('#');
188
+ });
189
+ }
190
+ /**
191
+ * Parse accountsYaml text into cards. Never throws: the editor calls this on
192
+ * every keystroke, so a half-typed document is the normal case and comes back
193
+ * as an error field rather than as an HTTP failure.
194
+ *
195
+ * The parsed mapping stays inside this function. It carries plaintext
196
+ * passwords, and the editor only ever consumes the cards — which project
197
+ * `hasPassword` instead of the secret — so no raw account leaves here.
198
+ */
199
+ function readAccountsDraft(text, presets, rowDefault, tokens = () => ({ state: 'none' })) {
200
+ if (isBlankAccountsText(text)) {
201
+ const { defaultAccount, error } = adjudicateAccounts({}, undefined, rowDefault);
202
+ return { ...(defaultAccount !== undefined ? { defaultAccount } : {}), list: [], ...(error !== undefined ? { error } : {}) };
203
+ }
204
+ let raw = {};
205
+ let yamlDefault;
206
+ try {
207
+ const parsed = parseAccountsYaml(text);
208
+ raw = parsed.map;
209
+ yamlDefault = parsed.defaultAccount;
210
+ }
211
+ catch (caught) {
212
+ // No mapping could be read: report it and hand back an empty draft.
213
+ return { list: [], error: messageOf(caught, 'accountsYaml 解析失败') };
214
+ }
215
+ const verdict = adjudicateAccounts(raw, yamlDefault, rowDefault);
216
+ return {
217
+ ...(verdict.defaultAccount !== undefined ? { defaultAccount: verdict.defaultAccount } : {}),
218
+ list: buildAccountCards(raw, verdict.defaultAccount, presets, tokens),
219
+ ...(verdict.error !== undefined ? { error: verdict.error } : {}),
220
+ };
221
+ }
222
+ /**
223
+ * The OAuth2 login state lookup for a card list. The token file is read once
224
+ * and answered from memory afterwards: the editor calls parseAccounts on every
225
+ * keystroke, and one disk read per card would be paid on each of them.
226
+ */
227
+ function tokenLookup() {
228
+ const cache = new Map();
229
+ return (name, user) => {
230
+ const key = name + '\u0000' + user;
231
+ const hit = cache.get(key);
232
+ if (hit !== undefined)
233
+ return hit;
234
+ const fresh = oauth2StateOf(name, user);
235
+ cache.set(key, fresh);
236
+ return fresh;
237
+ };
238
+ }
239
+ /** The raw key a Pair is stored under (an unquoted `163:` parses as a number). */
240
+ function rawKeyOf(pair) {
241
+ const key = pair.key;
242
+ return key !== null && typeof key === 'object' && 'value' in key ? key.value : key;
243
+ }
244
+ /** A Pair's key as text, so lookups match `163:` and `"163":` alike. */
245
+ function keyTextOf(pair) {
246
+ return String(rawKeyOf(pair));
247
+ }
248
+ /**
249
+ * Write one known account field: an empty or absent value deletes the key
250
+ * instead of storing '' (a stored provider: '' resolves as 「provider "" 未知」).
251
+ */
252
+ function writeField(node, key, value) {
253
+ if (value === undefined || value === '') {
254
+ node.delete(key);
255
+ return;
256
+ }
257
+ node.set(key, value);
258
+ }
259
+ /** A scalar/sequence value cannot carry account fields; swap in a map. */
260
+ function ensureMap(doc, node, key) {
261
+ const existing = node.get(key, true);
262
+ if (isMap(existing))
263
+ return existing;
264
+ const fresh = doc.createNode({});
265
+ // Set under the *stored* key, not its text form: `163:` parses as the number
266
+ // 163, and set('163', …) would add a second, quoted key beside it.
267
+ node.set(key, fresh);
268
+ return fresh;
269
+ }
270
+ /**
271
+ * Wash the endpoint fields out of one stored imap/smtp node.
272
+ *
273
+ * An account stores a provider id; host/port/secure are expanded from the
274
+ * preset at resolution time, so a hand-written copy is stale by definition and
275
+ * must not survive an edit. Only those three keys go: an advanced key the card
276
+ * does not model (socketTimeoutMs, connectionTimeoutMs) is not an endpoint and
277
+ * stays exactly where it was. An endpoint map left with nothing in it is
278
+ * removed, so the account keeps only the fields that still mean something.
279
+ */
280
+ function washEndpoint(account, key) {
281
+ const target = account.get(key, true);
282
+ if (!isMap(target))
283
+ return;
284
+ for (const field of ['host', 'port', 'secure'])
285
+ target.delete(field);
286
+ if (target.items.length === 0)
287
+ account.delete(key);
288
+ }
289
+ /**
290
+ * The `provider` value that may be written back to accountsYaml.
291
+ *
292
+ * The account stores a provider *id* and nothing else, so a name the provider
293
+ * table can resolve is exactly what belongs in the document: a built-in preset,
294
+ * or a custom `serverPresets` entry (resolveAccount() consults built-ins first
295
+ * and then the custom table). Anything else — a name the user typed that no
296
+ * preset defines, '' — is dropped, because persisting it would only produce
297
+ * 「账号 "work" 的 provider "corp" 未知」 on the very next connection.
298
+ *
299
+ * `customNames` is the set of preset names in effect for this request. The card
300
+ * editor posts its own control bundle, and an older front end sends no
301
+ * serverPresets at all: the empty set then reproduces the previous
302
+ * "built-ins only" behaviour rather than inventing names.
303
+ *
304
+ * hasOwnProperty, not a plain lookup: `constructor`/`toString` must not be
305
+ * mistaken for preset names through the prototype chain.
306
+ */
307
+ function persistedProvider(provider, customNames) {
308
+ if (typeof provider !== 'string' || provider === '')
309
+ return undefined;
310
+ if (Object.prototype.hasOwnProperty.call(PROVIDER_PRESETS, provider))
311
+ return provider;
312
+ return customNames.has(provider) ? provider : undefined;
313
+ }
314
+ /**
315
+ * Mirror of config.ts' normalizeAccountForYaml for the card shape: provider ''
316
+ * disappears (writing it back resolves as 「provider "" 未知」) and a numeric
317
+ * password becomes a string (YAML would otherwise read back a number).
318
+ *
319
+ * The card's `imap`/`smtp` are deliberately *not* written: an account stores a
320
+ * provider id, and the endpoints are expanded from the preset table at
321
+ * resolution time. Writing them back would freeze a stale copy of a preset the
322
+ * user may later edit.
323
+ *
324
+ * `inheritedPassword` is the value stored in the source YAML for this account.
325
+ * It is used only when the card carries no password at all — see the three-state
326
+ * contract on AccountCardInput: the card omits the field whenever the editor has
327
+ * nothing to say about it, which must leave the stored secret untouched.
328
+ */
329
+ function normalizeCardForYaml(card, customNames, inheritedPassword, inheritedAuthPassword) {
330
+ const out = {};
331
+ const provider = persistedProvider(card.provider, customNames);
332
+ if (provider !== undefined)
333
+ out.provider = provider;
334
+ if (card.user !== undefined)
335
+ out.user = card.user;
336
+ if (card.password === undefined) {
337
+ // 未提供 = 保持原样,且原样包括「原来是什么类型」:数字密码原样留下数字。
338
+ if (inheritedPassword !== undefined)
339
+ out.password = inheritedPassword;
340
+ }
341
+ else if (card.password === '') {
342
+ // '' = 明确清除:不写 password 键。
343
+ }
344
+ else {
345
+ out.password = typeof card.password === 'number' || typeof card.password === 'boolean'
346
+ ? String(card.password)
347
+ : card.password;
348
+ }
349
+ // Same three-state contract as the in-place writer: a card that does not
350
+ // model the field says nothing about it, '' clears it, a value is written
351
+ // trimmed. Losing this on the degraded path would silently log the account
352
+ // out of its application.
353
+ if (card.clientId !== undefined) {
354
+ const clientId = String(card.clientId).trim();
355
+ if (clientId !== '')
356
+ out.clientId = clientId;
357
+ }
358
+ // '' means「back to automatic」, which is the absence of the key.
359
+ if (card.authKind !== undefined) {
360
+ const authKind = String(card.authKind).trim().toLowerCase();
361
+ if (authKind !== '')
362
+ out.authKind = authKind;
363
+ }
364
+ if (card.inboxFolder !== undefined)
365
+ out.inboxFolder = card.inboxFolder;
366
+ if (card.senderName !== undefined) {
367
+ const senderName = String(card.senderName).trim();
368
+ if (senderName !== '')
369
+ out.senderName = senderName;
370
+ }
371
+ if (card.authUser !== undefined) {
372
+ const authUser = String(card.authUser).trim();
373
+ if (authUser !== '')
374
+ out.authUser = authUser;
375
+ }
376
+ if (card.authPassword === undefined) {
377
+ // Same contract as password: a card that says nothing must not delete it.
378
+ if (inheritedAuthPassword !== undefined)
379
+ out.authPassword = inheritedAuthPassword;
380
+ }
381
+ else if (card.authPassword !== '') {
382
+ out.authPassword = String(card.authPassword);
383
+ }
384
+ return out;
385
+ }
386
+ /** One secret key stored in the source YAML for one account, if it is there. */
387
+ function storedSecretOf(raw, name, key) {
388
+ const account = raw[name];
389
+ if (account === null || typeof account !== 'object' || Array.isArray(account))
390
+ return { present: false, value: undefined };
391
+ if (!Object.prototype.hasOwnProperty.call(account, key))
392
+ return { present: false, value: undefined };
393
+ return { present: true, value: account[key] };
394
+ }
395
+ function storedPasswordOf(raw, name) {
396
+ return storedSecretOf(raw, name, 'password');
397
+ }
398
+ function storedAuthPasswordOf(raw, name) {
399
+ return storedSecretOf(raw, name, 'authPassword');
400
+ }
401
+ /**
402
+ * Fallback writer: loses comments but keeps the semantics the cards describe.
403
+ *
404
+ * `source` is the draft being replaced, re-read only to recover passwords the
405
+ * cards do not carry — a card never holds a plaintext password, so without this
406
+ * every degraded save would silently drop the stored secrets. The read is
407
+ * best-effort by nature: this path is reached precisely when the document could
408
+ * not be edited in place, and an unparseable document cannot lend its passwords
409
+ * back. When that happens and a card has nothing to say about its password, the
410
+ * secret is gone and `passwordsDropped` says so.
411
+ */
412
+ function fallbackSerialize(cards, defaultAccount, source, customNames) {
413
+ let stored;
414
+ try {
415
+ stored = parseAccountsYaml(source).map;
416
+ }
417
+ catch {
418
+ stored = undefined;
419
+ }
420
+ let passwordsDropped = false;
421
+ const raw = {};
422
+ for (const card of cards) {
423
+ const name = card.name;
424
+ let inherited;
425
+ let inheritedAuthPassword;
426
+ if (stored === undefined) {
427
+ // Nothing could be read back: a silent card may be losing a real secret.
428
+ if (card.password === undefined)
429
+ passwordsDropped = true;
430
+ }
431
+ else {
432
+ const { present, value } = storedPasswordOf(stored, name);
433
+ if (present)
434
+ inherited = value;
435
+ const auth = storedAuthPasswordOf(stored, name);
436
+ if (auth.present)
437
+ inheritedAuthPassword = auth.value;
438
+ }
439
+ raw[name] = normalizeCardForYaml(card, customNames, inherited, inheritedAuthPassword);
440
+ }
441
+ return {
442
+ accountsYaml: serializeAccountsYaml(raw, defaultAccount),
443
+ ...(passwordsDropped ? { passwordsDropped: true } : {}),
444
+ };
445
+ }
446
+ /**
447
+ * Rewrite the accountsYaml draft from the card list.
448
+ *
449
+ * The card list is the complete desired set: an account key the cards do not
450
+ * name is removed, a name the document does not have is created, and a renamed
451
+ * card therefore deletes the old key and adds the new one. Editing happens on
452
+ * the parsed Document so comments survive and unknown keys (socketTimeoutMs,
453
+ * …) stay exactly where they were.
454
+ */
455
+ function serializeAccountsDraft(source, cards, defaultAccount, customNames) {
456
+ const doc = parseDocument(source);
457
+ // parseDocument never throws — a broken document surfaces as doc.errors, and
458
+ // String(doc) then refuses to run at all. Degrade to the stringify path.
459
+ if (doc.errors.length > 0)
460
+ return { ...fallbackSerialize(cards, defaultAccount, source, customNames), commentsDropped: true };
461
+ let root;
462
+ if (doc.contents === null) {
463
+ root = doc.createNode({});
464
+ doc.contents = root;
465
+ }
466
+ else if (isMap(doc.contents)) {
467
+ root = doc.contents;
468
+ }
469
+ else {
470
+ // A sequence or scalar document cannot carry accounts at all.
471
+ return { ...fallbackSerialize(cards, defaultAccount, source, customNames), commentsDropped: true };
472
+ }
473
+ const desired = new Set(cards.map(card => card.name));
474
+ if (desired.size !== cards.length)
475
+ throw new Error('账号名重复,不能覆盖已有账号');
476
+ // Rename the YAML node before filtering, retaining secrets and advanced keys.
477
+ // originalName may already have been renamed by a preceding auto-save.
478
+ const renameTargets = new Set();
479
+ for (const card of cards) {
480
+ const from = card.originalName;
481
+ const to = card.name;
482
+ if (!from || from === to || !root.has(from))
483
+ continue;
484
+ if (root.has(to) || renameTargets.has(from))
485
+ throw new Error('账号名已存在,不能覆盖已有账号');
486
+ const pair = root.items.find(item => keyTextOf(item) === from);
487
+ if (pair !== undefined) {
488
+ pair.key = doc.createNode(to);
489
+ renameTargets.add(from);
490
+ }
491
+ }
492
+ const seen = new Set();
493
+ for (const pair of [...root.items]) {
494
+ const name = keyTextOf(pair);
495
+ if (name === 'defaultAccount')
496
+ continue;
497
+ // Remove accounts the cards dropped, and collapse a duplicate spelling
498
+ // (`163:` and `"163":`) onto a single key.
499
+ if (!desired.has(name) || seen.has(name)) {
500
+ root.delete(rawKeyOf(pair));
501
+ continue;
502
+ }
503
+ seen.add(name);
504
+ }
505
+ for (const card of cards) {
506
+ const name = card.name;
507
+ const pair = root.items.find(item => keyTextOf(item) === name);
508
+ let account;
509
+ if (pair === undefined) {
510
+ account = doc.createNode({});
511
+ root.set(name, account);
512
+ }
513
+ else {
514
+ // Reuse the stored key node (163 vs "163") so the name keeps its spelling.
515
+ account = ensureMap(doc, root, rawKeyOf(pair));
516
+ }
517
+ // The provider is the account's whole connection identity: a resolvable
518
+ // preset name is written, anything else deletes the key. Endpoints are
519
+ // never written — a stored copy is washed out below instead.
520
+ const previousProvider = account.get('provider');
521
+ const nextProvider = persistedProvider(card.provider, customNames);
522
+ writeField(account, 'provider', nextProvider);
523
+ writeField(account, 'user', card.user);
524
+ // senderName / authUser are plain three-state fields (undefined = 保持原样,
525
+ // '' = 清除, 非空 = 写入) — writeField already implements exactly that.
526
+ // An undefined field means "this card says nothing" — writeField would read
527
+ // that as「delete」, so the guard has to live here, not inside it.
528
+ if (card.senderName !== undefined)
529
+ writeField(account, 'senderName', String(card.senderName).trim());
530
+ if (card.authUser !== undefined)
531
+ writeField(account, 'authUser', String(card.authUser).trim());
532
+ // authPassword is a secret and follows the password contract verbatim: the
533
+ // card never carries the plaintext, so an omitted field must leave the
534
+ // stored key — value, position and comment — untouched.
535
+ if (card.authPassword === undefined) {
536
+ // 未提供 = 保持原样:什么都不写。
537
+ }
538
+ else if (card.authPassword === '') {
539
+ account.delete('authPassword');
540
+ }
541
+ else {
542
+ account.set('authPassword', String(card.authPassword));
543
+ }
544
+ // Password is three-state, unlike every other field: the card is never given
545
+ // the plaintext (snapshot exposes hasPassword only), so an omitted password
546
+ // means "the editor has nothing to say" and the stored key must survive
547
+ // untouched — value, position and comment. Only an explicit '' clears it.
548
+ if (card.password === undefined) {
549
+ // 未提供 = 保持原样:什么都不写。
550
+ }
551
+ else if (card.password === '') {
552
+ account.delete('password');
553
+ }
554
+ else {
555
+ // Same coercion as normalizeAccountForYaml: a numeric password must be
556
+ // written as a string, or YAML reads it back as a number.
557
+ account.set('password', typeof card.password === 'number' || typeof card.password === 'boolean'
558
+ ? String(card.password)
559
+ : card.password);
560
+ }
561
+ // clientId is three-state for the same reason: a card that does not model
562
+ // the field — a password account, an older editor — has nothing to say about
563
+ // it, and a value the user typed into the YAML must survive an unrelated
564
+ // save. Only an explicit '' clears it.
565
+ if (card.clientId === undefined) {
566
+ // 未提供 = 保持原样:什么都不写。
567
+ }
568
+ else {
569
+ writeField(account, 'clientId', String(card.clientId).trim());
570
+ }
571
+ // Same three states, and here '' is meaningful rather than merely empty: it
572
+ // takes the pin back off, returning the account to the derivation.
573
+ if (card.authKind !== undefined) {
574
+ writeField(account, 'authKind', String(card.authKind).trim().toLowerCase());
575
+ }
576
+ writeField(account, 'inboxFolder', card.inboxFolder);
577
+ // Endpoints are never written by a card: they come from the preset, or from
578
+ // what the account already stores. They are washed only when the provider
579
+ // actually changed, because that is the one case where the stored copy is
580
+ // stale by definition — it belongs to the previous provider. Washing on
581
+ // every save would repoint a self-hosted account at the preset merely
582
+ // because the user opened this panel.
583
+ if (nextProvider !== undefined && nextProvider !== previousProvider) {
584
+ washEndpoint(account, 'imap');
585
+ washEndpoint(account, 'smtp');
586
+ }
587
+ }
588
+ if (defaultAccount !== '')
589
+ root.set('defaultAccount', defaultAccount);
590
+ else
591
+ root.delete('defaultAccount');
592
+ // No accounts left: '' (never '{}'). resolveEmailSettings decides "is the
593
+ // YAML authoritative" with .trim(), so an empty mapping must stay empty.
594
+ const accountKeys = root.items.filter(pair => keyTextOf(pair) !== 'defaultAccount').length;
595
+ if (accountKeys === 0)
596
+ return { accountsYaml: '' };
597
+ return { accountsYaml: String(doc) };
598
+ }
18
599
  let whaleCache;
19
600
  function pickFromDir(dir) {
20
601
  let names = [];
@@ -69,6 +650,96 @@ function findWhaleAsset() {
69
650
  }
70
651
  return whaleCache;
71
652
  }
653
+ /**
654
+ * The account names a settings value carries, or `undefined` when the document
655
+ * could not be read.
656
+ *
657
+ * Three states on purpose. An empty text really does mean "no accounts", but an
658
+ * unparseable one means "no idea" — and a caller that confused the two would
659
+ * treat every account as deleted and wipe their stored OAuth2 tokens.
660
+ */
661
+ function accountNamesOf(value) {
662
+ const text = typeof value?.accountsYaml === 'string' ? value.accountsYaml : '';
663
+ if (text.trim() === '')
664
+ return new Set();
665
+ try {
666
+ return new Set(Object.keys(parseAccountsYaml(text).map));
667
+ }
668
+ catch {
669
+ return undefined;
670
+ }
671
+ }
672
+ /** Hostnames a browser may legitimately reach this route through. */
673
+ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
674
+ /**
675
+ * Verdict on the `Host` header: `undefined` to proceed, otherwise the reason to
676
+ * refuse.
677
+ *
678
+ * The localhost gate on `socket.remoteAddress` proves where the packets came
679
+ * from, not which name the browser believes it is talking to. A page the user
680
+ * visits can point a domain at 127.0.0.1 (DNS rebinding); that request looks
681
+ * same-origin to the browser, carries no `Origin`, and would make the snapshot
682
+ * readable — and the snapshot carries `accountsYaml`, plaintext 授权码 included.
683
+ * Requiring a localhost `Host` closes it.
684
+ *
685
+ * A request with no `Host` at all did not come from a browser (HTTP/1.0, curl,
686
+ * the test harness), and the remote-address gate still applies to it.
687
+ */
688
+ export function hostVerdict(host) {
689
+ if (typeof host !== 'string' || host.trim() === '')
690
+ return undefined;
691
+ const text = host.trim().toLowerCase();
692
+ // An IPv6 literal arrives bracketed — `[::1]:3080` — where splitting on the
693
+ // colon would leave「[」and refuse a legitimate way to reach the panel.
694
+ const name = text.startsWith('[') ? text.slice(0, text.indexOf(']') + 1) : text.split(':')[0];
695
+ return LOCAL_HOSTNAMES.has(name) ? undefined : `host "${host}" is not a localhost name`;
696
+ }
697
+ /**
698
+ * Verdict on a state-changing POST: `undefined` to proceed, otherwise the status
699
+ * and reason to refuse.
700
+ *
701
+ * Cross-origin writes are the hole the remote-address gate cannot see: a browser
702
+ * page may POST here as a「simple request」(text/plain, no preflight) and change
703
+ * settings or trigger a dial. Three independent checks close it:
704
+ *
705
+ * - `application/json` is not a simple-request content type, so a cross-origin
706
+ * caller is forced into a preflight, which this route never answers with
707
+ * `Access-Control-Allow-Origin`.
708
+ * - `Origin`, when it names an http(s) page, must be a localhost origin. Other
709
+ * schemes are left to the next check: the host may load its UI through a
710
+ * custom protocol, and a hostile page cannot produce one.
711
+ * - `Sec-Fetch-Site`, when present, must be `same-origin` (or `none`, a
712
+ * user-initiated navigation with no referrer). This is what catches an opaque
713
+ * `Origin: null` from a sandboxed iframe.
714
+ *
715
+ * Headers a non-browser client omits are not fabricatable by page script, so
716
+ * their absence is allowed rather than treated as a rejection.
717
+ */
718
+ export function postVerdict(headers) {
719
+ const contentType = String(headers['content-type'] ?? '');
720
+ if (!contentType.toLowerCase().includes('application/json')) {
721
+ return { status: 415, message: 'dsh-email settings route accepts application/json only' };
722
+ }
723
+ const origin = headers.origin;
724
+ if (typeof origin === 'string' && origin.trim() !== '') {
725
+ let url;
726
+ try {
727
+ url = new URL(origin);
728
+ }
729
+ catch {
730
+ url = undefined;
731
+ }
732
+ if (url !== undefined && (url.protocol === 'http:' || url.protocol === 'https:')
733
+ && !LOCAL_HOSTNAMES.has(url.hostname.toLowerCase())) {
734
+ return { status: 403, message: `origin "${origin}" is not allowed to write dsh-email settings` };
735
+ }
736
+ }
737
+ const site = headers['sec-fetch-site'];
738
+ if (typeof site === 'string' && site !== '' && site !== 'same-origin' && site !== 'none') {
739
+ return { status: 403, message: `a ${site} request may not write dsh-email settings` };
740
+ }
741
+ return undefined;
742
+ }
72
743
  /**
73
744
  * Browser-facing backend: snapshot the settings namespace, save it with
74
745
  * optimistic concurrency, and test a draft account over a live IMAP login.
@@ -90,12 +761,22 @@ export class EmailSettingsBackend {
90
761
  }
91
762
  /** Effective config for the stored value (row + user-set fields only). */
92
763
  effectiveStored() {
93
- return { ...this.rowConfig, ...toEmailConfig(this.scope.get(), this.userSection()) };
764
+ const stored = this.scope.get();
765
+ const merged = { ...this.rowConfig, ...toEmailConfig(stored, this.userSection()) };
766
+ // toEmailConfig drops serverPresets — it must never enter the fingerprint —
767
+ // but resolution needs it as a provider lookup source. The scope value
768
+ // already resolves row-vs-user precedence for it, so it is re-added as-is.
769
+ return { ...merged, ...(typeof stored?.serverPresets === 'string' ? { serverPresets: stored.serverPresets } : {}) };
94
770
  }
95
771
  async snapshot() {
96
772
  const descriptor = (this.ctx.settings.describe?.() ?? []).find((row) => row.ns === SETTINGS_NAMESPACE);
97
773
  const value = this.scope.get();
98
774
  const whale = findWhaleAsset();
775
+ const presets = presetsSnapshot(value.serverPresets);
776
+ // The cards describe the *effective* accountsYaml — the same text the
777
+ // advanced editor shows, and the same source the accounts field reads.
778
+ const effective = this.effectiveStored();
779
+ const draft = readAccountsDraft(effective.accountsYaml ?? '', presets.custom, effective.defaultAccount, tokenLookup());
99
780
  return {
100
781
  settings: {
101
782
  value,
@@ -104,6 +785,12 @@ export class EmailSettingsBackend {
104
785
  },
105
786
  writable: this.ctx.settings.writable !== false,
106
787
  accounts: [...(this.effectiveAccounts().keys())],
788
+ accountsDetail: {
789
+ ...(draft.defaultAccount !== undefined ? { defaultAccount: draft.defaultAccount } : {}),
790
+ list: draft.list,
791
+ ...(draft.error !== undefined ? { error: draft.error } : {}),
792
+ },
793
+ presets,
107
794
  whale: whale === null
108
795
  ? { url: '', skin: false, credit: '' }
109
796
  : { url: WHALE_ASSET_ROUTE, skin: whale.skin, credit: whale.credit },
@@ -120,27 +807,78 @@ export class EmailSettingsBackend {
120
807
  async save(value, expectedRevision) {
121
808
  if (this.ctx.settings.writable === false)
122
809
  throw new Error('settings provider is read-only');
123
- validateSettingsValue(value);
810
+ // The provider dropdown offers the custom preset names, so a value naming
811
+ // one of them is a legal choice rather than an unknown provider.
812
+ validateSettingsValue(value, presetNamesIn(value?.serverPresets ?? this.scope.get()?.serverPresets));
813
+ const before = accountNamesOf(this.scope.get());
124
814
  await this.ctx.settings.replace(SETTINGS_NAMESPACE, value, expectedRevision);
815
+ // A deleted account must not leave its refresh token behind: the store is
816
+ // keyed by account name, so the credential of a mailbox that is no longer
817
+ // configured would sit on disk, and a later account reusing that name would
818
+ // inherit it. Only once the write has committed — a draft, or a 409, must
819
+ // not cost anybody their login. And never on an unreadable document: not
820
+ // knowing the new account list is not the same as knowing it is empty.
821
+ const after = accountNamesOf(value);
822
+ if (before !== undefined && after !== undefined) {
823
+ for (const name of before) {
824
+ if (!after.has(name))
825
+ clearTokenFor(name);
826
+ }
827
+ }
125
828
  return this.snapshot();
126
829
  }
127
- async test(value) {
128
- validateSettingsValue(value);
830
+ /**
831
+ * Test one account (by name, defaulting to the draft's default account) over
832
+ * a live IMAP login. Returns the endpoint it dialled so the panel can show
833
+ * what was actually tried — including on failure.
834
+ */
835
+ async test(value, accountName) {
836
+ validateSettingsValue(value, presetNamesIn(value?.serverPresets ?? this.scope.get()?.serverPresets));
129
837
  // null projects the complete draft: test the form as the user typed it.
130
- const settings = resolveEmailSettings({ ...this.rowConfig, ...toEmailConfig(value, null) });
838
+ // serverPresets rides along as the provider lookup source, exactly as it
839
+ // does for the stored settings (it never enters the resolved fingerprint).
840
+ const draft = toEmailConfig(value, null);
841
+ const presets = value?.serverPresets ?? this.scope.get()?.serverPresets;
842
+ const settings = resolveEmailSettings({
843
+ ...this.rowConfig,
844
+ ...draft,
845
+ ...(typeof presets === 'string' ? { serverPresets: presets } : {}),
846
+ });
847
+ const requested = typeof accountName === 'string' && accountName.trim() !== '' ? accountName.trim() : '';
848
+ const available = [...settings.accounts.keys()];
849
+ const name = requested !== '' ? requested : settings.defaultAccount;
850
+ const cfg = settings.accounts.get(name);
851
+ if (cfg === undefined) {
852
+ throw new Error(`未知账号 "${name}",可用:${available.join('、')}`);
853
+ }
854
+ const target = { account: name, imapHost: cfg.imap.host, imapPort: cfg.imap.port };
855
+ // An OAuth2 account has no password to check: without a token there is
856
+ // nothing to dial with, and a failed dial would only say so less clearly.
857
+ if (cfg.authKind === 'oauth2') {
858
+ try {
859
+ await getFreshAccessToken(name, cfg);
860
+ }
861
+ catch (error) {
862
+ throw new Error(messageOf(error, '尚未登录:请先在设置页完成设备码登录'));
863
+ }
864
+ }
131
865
  const pool = new EmailPool(settings);
132
866
  try {
133
867
  const started = Date.now();
134
- await pool.withImap(settings.defaultAccount, null, async () => 'connected');
135
- return { ok: true, ms: Date.now() - started };
868
+ await pool.withImap(name, null, async () => 'connected');
869
+ return { ok: true, ms: Date.now() - started, ...target };
136
870
  }
137
871
  catch (error) {
138
872
  // 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');
873
+ // actionable hint instead of the opaque message. The server's own text is
874
+ // redacted first: a refused authentication string is echoed verbatim by
875
+ // many servers, and for XOAUTH2 that blob carries the access token.
876
+ const raw = redactCredentials(messageOf(error, 'unknown error'));
141
877
  const lower = raw.toLowerCase();
142
878
  if (lower.includes('command failed') || lower.includes('authentication') || lower.includes('login')) {
143
- throw new Error('邮箱登录失败:请检查邮箱地址与授权码(' + raw + ')');
879
+ throw new Error(cfg.authKind === 'oauth2'
880
+ ? '邮箱登录失败:请在设置页重新完成设备码登录(' + raw + ')'
881
+ : '邮箱登录失败:请检查邮箱地址与授权码(' + raw + ')');
144
882
  }
145
883
  throw error;
146
884
  }
@@ -148,6 +886,86 @@ export class EmailSettingsBackend {
148
886
  pool.dispose();
149
887
  }
150
888
  }
889
+ /**
890
+ * Resolve one named account of the *stored* settings — the same accounts the
891
+ * tools and the card list see. A login is not a draft operation: the settings
892
+ * page saves the card before it starts one, so the account being logged into
893
+ * is by definition already persisted.
894
+ */
895
+ oauthAccount(name, action) {
896
+ const wanted = typeof name === 'string' ? name.trim() : '';
897
+ if (wanted === '')
898
+ throw new Error(action + ' 需要 account 参数(账号名)');
899
+ let settings;
900
+ try {
901
+ settings = resolveEmailSettings(this.effectiveStored());
902
+ }
903
+ catch (error) {
904
+ throw new Error(messageOf(error, '邮箱账号未配置'));
905
+ }
906
+ const cfg = settings.accounts.get(wanted);
907
+ if (cfg === undefined) {
908
+ throw new Error('未知账号 "' + wanted + '",可用:' + [...settings.accounts.keys()].join('、'));
909
+ }
910
+ if (cfg.authKind !== 'oauth2') {
911
+ // The card only offers the login button on an OAuth2 account; reaching
912
+ // here means the page is stale or the provider was just changed.
913
+ throw new Error('账号 "' + wanted + '" 不需要设备码登录:只有 outlook(Exchange Online)账号使用 OAuth2');
914
+ }
915
+ return { name: wanted, cfg };
916
+ }
917
+ /**
918
+ * Start (or report) the device-code login for one OAuth2 account.
919
+ *
920
+ * An account that already holds a token answers `already` — the card shows
921
+ * 「已登录」and there is no second code to hand out. Otherwise the authority's
922
+ * device code is returned verbatim: url = verification_uri, code = user_code,
923
+ * and both interval and expires_in in seconds, which is the unit the page
924
+ * schedules its polling with.
925
+ */
926
+ async oauthLogin(name) {
927
+ try {
928
+ const { name: account, cfg } = this.oauthAccount(name, 'oauthLogin');
929
+ // Same verdict the card renders: a token belonging to a different
930
+ // address is not a login for this account, so it starts a fresh flow.
931
+ const state = oauth2StateOf(account, cfg.user, cfg.clientId ?? '');
932
+ if (state.state === 'logged-in')
933
+ return { ok: true, status: 'already' };
934
+ const start = await startDeviceFlow(account, cfg);
935
+ return {
936
+ ok: true,
937
+ status: 'pending',
938
+ url: start.url,
939
+ code: start.code,
940
+ interval: start.interval,
941
+ expires_in: start.expiresIn,
942
+ };
943
+ }
944
+ catch (error) {
945
+ return { ok: false, message: messageOf(error, '登录失败:请稍后重试') };
946
+ }
947
+ }
948
+ /**
949
+ * One poll of an in-flight device-code login.
950
+ *
951
+ * `authorization_pending` is the ordinary answer for as long as the user has
952
+ * not finished in the browser, so it is reported as a state rather than an
953
+ * error: only a refused or expired flow comes back as ok:false.
954
+ */
955
+ async oauthPoll(name) {
956
+ try {
957
+ const { name: account } = this.oauthAccount(name, 'oauthPoll');
958
+ const result = await pollDeviceFlow(account);
959
+ if (result.status === 'pending')
960
+ return { ok: true, status: 'pending' };
961
+ return { ok: true, status: 'ok', user: result.user };
962
+ }
963
+ catch (error) {
964
+ // Every failure of a poll is a login failure the user has to see, so it
965
+ // leaves as the flat { ok:false, message } the page renders.
966
+ return { ok: false, message: messageOf(error, '登录失败:请稍后重试') };
967
+ }
968
+ }
151
969
  responseJson(res, status, body) {
152
970
  const bytes = Buffer.from(JSON.stringify(body));
153
971
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
@@ -165,6 +983,28 @@ export class EmailSettingsBackend {
165
983
  this.responseJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-email settings route is localhost-only' } });
166
984
  return;
167
985
  }
986
+ // Localhost is where the packets came from, not which name the browser thinks
987
+ // it reached: a rebound domain resolves to 127.0.0.1 and would otherwise read
988
+ // the snapshot, `accountsYaml` and its plaintext 授权码 included.
989
+ const headers = (req.headers ?? {});
990
+ const host = hostVerdict(headers.host);
991
+ if (host !== undefined) {
992
+ this.responseJson(res, 403, { ok: false, error: { code: 'forbidden', message: host } });
993
+ return;
994
+ }
995
+ if (req.method === 'POST') {
996
+ // A page the user visits can POST here cross-origin as a「simple request」
997
+ // and change settings or trigger a dial; these headers are the part of that
998
+ // a page script cannot forge away.
999
+ const post = postVerdict(headers);
1000
+ if (post !== undefined) {
1001
+ this.responseJson(res, post.status, {
1002
+ ok: false,
1003
+ error: { code: post.status === 415 ? 'unsupported-media-type' : 'forbidden', message: post.message },
1004
+ });
1005
+ return;
1006
+ }
1007
+ }
168
1008
  if (req.method === 'GET') {
169
1009
  try {
170
1010
  this.responseJson(res, 200, { ok: true, value: await this.snapshot() });
@@ -195,13 +1035,23 @@ export class EmailSettingsBackend {
195
1035
  return;
196
1036
  }
197
1037
  try {
198
- if (body?.action === 'save') {
1038
+ if (body?.action === 'oauthLogin' || body?.action === 'oauthPoll') {
1039
+ // The two login actions answer with the flat object itself, not the
1040
+ // { ok, value } envelope the other actions use: their whole meaning is
1041
+ // { ok, status }, and a failure is a flat { ok:false, message } rather
1042
+ // than the error envelope. The page reads them straight off the body.
1043
+ const answer = body.action === 'oauthLogin'
1044
+ ? await this.oauthLogin(body.account)
1045
+ : await this.oauthPoll(body.account);
1046
+ this.responseJson(res, 200, answer);
1047
+ }
1048
+ else if (body?.action === 'save') {
199
1049
  if (!Number.isSafeInteger(body.expectedRevision))
200
1050
  throw new Error('expectedRevision must be a non-negative integer');
201
1051
  this.responseJson(res, 200, { ok: true, value: await this.save(body.value, body.expectedRevision) });
202
1052
  }
203
1053
  else if (body?.action === 'test') {
204
- this.responseJson(res, 200, { ok: true, value: await this.test(body.value) });
1054
+ this.responseJson(res, 200, { ok: true, value: await this.test(body.value, body.account) });
205
1055
  }
206
1056
  else if (body?.action === 'watch') {
207
1057
  if (typeof this.watchImpl !== 'function')
@@ -211,6 +1061,59 @@ export class EmailSettingsBackend {
211
1061
  const limit = Number.isSafeInteger(body.limit) ? body.limit : 5;
212
1062
  this.responseJson(res, 200, { ok: true, value: await this.watchImpl(account, folder, limit, 'web') });
213
1063
  }
1064
+ else if (body?.action === 'parseAccounts') {
1065
+ // Pure and always 200: the editor calls this on every keystroke, so a
1066
+ // half-typed document is the normal case, not an HTTP failure.
1067
+ const text = typeof body.value?.accountsYaml === 'string' ? body.value.accountsYaml : '';
1068
+ const presets = typeof body.serverPresets === 'string'
1069
+ ? customPresetsOf(body.serverPresets).custom
1070
+ : customPresetsOf(this.scope.get().serverPresets).custom;
1071
+ const draft = readAccountsDraft(text, presets, this.rowConfig.defaultAccount, tokenLookup());
1072
+ this.responseJson(res, 200, {
1073
+ ok: true,
1074
+ value: {
1075
+ ok: draft.error === undefined,
1076
+ ...(draft.error !== undefined ? { error: draft.error } : {}),
1077
+ ...(draft.defaultAccount !== undefined ? { defaultAccount: draft.defaultAccount } : {}),
1078
+ list: draft.list,
1079
+ },
1080
+ });
1081
+ }
1082
+ else if (body?.action === 'serializeAccounts') {
1083
+ if (!Array.isArray(body.accounts))
1084
+ throw new Error('accounts 必须是数组');
1085
+ const cards = [];
1086
+ for (const entry of body.accounts) {
1087
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
1088
+ throw new Error('accounts 的每一项都必须是对象');
1089
+ const name = typeof entry.name === 'string' ? entry.name.trim() : '';
1090
+ if (name === '')
1091
+ throw new Error('accounts 的每一项都需要非空的 name');
1092
+ if (name === 'defaultAccount')
1093
+ throw new Error('账号名不能是 defaultAccount(该键保留给默认账号)');
1094
+ cards.push({ ...entry, name });
1095
+ }
1096
+ const source = typeof body.accountsYaml === 'string' ? body.accountsYaml : '';
1097
+ const chosen = typeof body.defaultAccount === 'string' ? body.defaultAccount.trim() : '';
1098
+ // The body may carry the preset table the page is editing: a custom
1099
+ // preset name is only persistable when the table in effect defines it.
1100
+ // An older front end sends none, which reproduces "built-ins only".
1101
+ const bodyPresets = typeof body.serverPresets === 'string'
1102
+ ? customPresetsOf(body.serverPresets).custom
1103
+ : customPresetsOf(this.scope.get().serverPresets).custom;
1104
+ const written = serializeAccountsDraft(source, cards, chosen, new Set(Object.keys(bodyPresets)));
1105
+ this.responseJson(res, 200, {
1106
+ ok: true,
1107
+ value: {
1108
+ accountsYaml: written.accountsYaml,
1109
+ ...(written.commentsDropped === true ? { commentsDropped: true } : {}),
1110
+ // A card that says nothing about its password keeps the stored one;
1111
+ // this flag is the honest signal that it could not be kept because
1112
+ // the source document itself was unreadable.
1113
+ ...(written.passwordsDropped === true ? { passwordsDropped: true } : {}),
1114
+ },
1115
+ });
1116
+ }
214
1117
  else {
215
1118
  this.responseJson(res, 400, { ok: false, error: { code: 'invalid-request', message: 'unsupported action' } });
216
1119
  }