dsh-email 0.10.7 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +81 -8
- package/README.md +61 -9
- package/lib/client.js +2248 -182
- package/lib/config.d.ts +141 -1
- package/lib/config.js +231 -10
- package/lib/index.d.ts +6 -2
- package/lib/index.js +3 -2
- package/lib/mail-client.d.ts +90 -1
- package/lib/mail-client.js +214 -33
- package/lib/oauth2.d.ts +124 -0
- package/lib/oauth2.js +417 -0
- package/lib/runtime.js +16 -3
- package/lib/settings.d.ts +22 -1
- package/lib/settings.js +122 -34
- package/lib/tool-contract.js +1 -1
- package/lib/tools.js +20 -5
- package/lib/web.d.ts +186 -2
- package/lib/web.js +861 -14
- package/package.json +1 -1
package/lib/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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 === '
|
|
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
|
}
|