dsh-email 0.10.7 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/config.d.ts CHANGED
@@ -1,4 +1,45 @@
1
+ /** The 8 built-in provider ids. A `provider` may also name a custom preset. */
1
2
  export type ProviderName = 'qq' | '163' | '126' | 'sina' | 'aliyun' | 'gmail' | 'outlook' | 'icloud';
3
+ /**
4
+ * A provider id as an account stores it: one of the built-in names, or the name
5
+ * of a custom `serverPresets` entry. The union keeps autocomplete for the
6
+ * built-ins while admitting a preset name the schema cannot know in advance.
7
+ */
8
+ export type ProviderRef = ProviderName | (string & {});
9
+ /**
10
+ * The provider that authenticates with OAuth2 instead of a password.
11
+ *
12
+ * Microsoft retired basic authentication for Exchange Online, so `outlook` is
13
+ * not a password provider with different endpoints — it is the same endpoints
14
+ * (imap/smtp.office365.com, straight out of PROVIDER_PRESETS) behind a
15
+ * completely different authentication scheme. `authKind` below is that fact.
16
+ */
17
+ export declare const OUTLOOK_PROVIDER = "outlook";
18
+ /** The Exchange Online IMAP host. Any account dialling it is an OAuth2 account. */
19
+ export declare const OUTLOOK_IMAP_HOST = "outlook.office365.com";
20
+ /**
21
+ * The built-in default client id for the device-code flow: deliberately empty.
22
+ *
23
+ * An earlier revision shipped a registration belonging to a contributor. That
24
+ * cannot be right for a package thousands of strangers install: the Microsoft
25
+ * consent screen would name someone else's application (which enterprise
26
+ * security teams refuse), the sign-in logs and telemetry would land in their
27
+ * tenant along with the user's UPN, and their deleting the app would break
28
+ * every login at once — with an error that only says the client id「可能填错了」,
29
+ * so no user could diagnose it.
30
+ *
31
+ * Nothing third-party is baked in, so an account supplies its own `clientId`
32
+ * (a free Entra public-client registration; the README walks through it) and
33
+ * `startDeviceFlow` refuses with an actionable message until one is set. A
34
+ * maintainer who registers an application for this project restores the
35
+ * out-of-box experience by filling in this one constant.
36
+ */
37
+ export declare const OUTLOOK_OAUTH2_CLIENT_ID = "";
38
+ /**
39
+ * How an account proves who it is. `password` covers every existing provider
40
+ * (an app password / 授权码) and is the default, so nothing about them changes.
41
+ */
42
+ export type AuthKind = 'oauth2' | 'password';
2
43
  export interface ImapConfig {
3
44
  host?: string;
4
45
  port?: number;
@@ -13,9 +54,25 @@ export interface SmtpConfig {
13
54
  }
14
55
  /** One mailbox account. Top-level shorthand fields act as shared defaults. */
15
56
  export interface AccountConfig {
16
- provider?: ProviderName;
57
+ /** Built-in provider name, or a custom serverPresets name. */
58
+ provider?: ProviderRef;
17
59
  user?: string;
18
60
  password?: string;
61
+ /**
62
+ * Public-client id used by the OAuth2 device-code flow. Only read for an
63
+ * OAuth2 account, where it overrides OUTLOOK_OAUTH2_CLIENT_ID.
64
+ */
65
+ clientId?: string;
66
+ /**
67
+ * Escape hatch over the derived authentication scheme. Left unset, an account
68
+ * pointed at the `outlook` provider or the Exchange Online IMAP host is an
69
+ * OAuth2 account and its password is dropped. Set `password` to keep using an
70
+ * app password there — a tenant that still accepts basic auth (hybrid or
71
+ * on-prem, SMTP AUTH left enabled), or a mailbox that worked before this
72
+ * derivation existed, must not be told「尚未登录」after an upgrade. Set
73
+ * `oauth2` to opt in from a custom host.
74
+ */
75
+ authKind?: AuthKind;
19
76
  imap?: ImapConfig;
20
77
  smtp?: SmtpConfig;
21
78
  inboxFolder?: string;
@@ -29,6 +86,12 @@ export interface EmailConfig extends AccountConfig {
29
86
  accounts?: Record<string, AccountConfig>;
30
87
  /** YAML text of the accounts map, editable from the settings page. Wins over accounts when non-empty. */
31
88
  accountsYaml?: string;
89
+ /**
90
+ * YAML text of the reusable server presets (connection endpoints only).
91
+ * Deliberately never part of ResolvedEmailSettings: editing a preset must not
92
+ * change the pool fingerprint and tear down live IMAP connections.
93
+ */
94
+ serverPresets?: string;
32
95
  /** Which account tools use when the call omits account. Required with 2+ accounts. */
33
96
  defaultAccount?: string;
34
97
  /** Directory email_attachment writes into. Default: the session workspace's .dsh-email-downloads (falls back to $DSH_HOME/email-downloads). */
@@ -54,13 +117,42 @@ export interface ProviderPreset {
54
117
  secure: boolean;
55
118
  };
56
119
  }
120
+ /**
121
+ * Anything that can stand in for a provider: a built-in preset, or a custom
122
+ * `serverPresets` entry (whose port/secure are optional and whose label is
123
+ * editor-facing only). Both are looked up the same way.
124
+ */
125
+ export interface EndpointPreset {
126
+ imap: {
127
+ host: string;
128
+ port?: number;
129
+ secure?: boolean;
130
+ };
131
+ smtp: {
132
+ host: string;
133
+ port?: number;
134
+ secure?: boolean;
135
+ };
136
+ label?: string;
137
+ }
57
138
  export declare const PROVIDER_PRESETS: Record<string, ProviderPreset>;
58
139
  export declare const PROVIDER_NAMES: string[];
59
140
  export declare const EMAIL_PASSWORD_ENV = "DSH_EMAIL_PASSWORD";
60
141
  /** Fully resolved, validated configuration for one account. */
61
142
  export interface ResolvedEmailConfig {
62
143
  user: string;
144
+ /**
145
+ * The app password / 授权码. Empty for an OAuth2 account — that is the point:
146
+ * nothing is stored, the token store holds the credential instead.
147
+ */
63
148
  password: string;
149
+ /**
150
+ * How this account authenticates. Derived from `provider` / the IMAP host
151
+ * unless the account pins it with an explicit `authKind`.
152
+ */
153
+ authKind: AuthKind;
154
+ /** Public-client id for the device-code flow (OAuth2 accounts only). */
155
+ clientId?: string;
64
156
  imap: ImapConfig & {
65
157
  host: string;
66
158
  port: number;
@@ -73,6 +165,16 @@ export interface ResolvedEmailConfig {
73
165
  };
74
166
  inboxFolder: string;
75
167
  }
168
+ /**
169
+ * True when an account authenticates with OAuth2 rather than a password.
170
+ *
171
+ * Two ways in, and only these two: the built-in `outlook` provider, or an
172
+ * account pointed at the Exchange Online IMAP host by hand (a custom preset or
173
+ * an explicit `imap.host`). The host test is what keeps a custom preset to
174
+ * outlook.office365.com from silently demanding a password Microsoft no longer
175
+ * accepts — the endpoints are identical, only the credentials are not.
176
+ */
177
+ export declare function isOAuth2Account(provider: string | undefined, imapHost: string | undefined): boolean;
76
178
  /** Fully resolved plugin settings: the account map plus shared policy. */
77
179
  export interface ResolvedEmailSettings {
78
180
  accounts: Map<string, ResolvedEmailConfig>;
@@ -96,12 +198,50 @@ export declare function parseAccountsYaml(text: string): {
96
198
  map: Record<string, AccountConfig>;
97
199
  defaultAccount?: string;
98
200
  };
201
+ /** Reusable IMAP/SMTP endpoints. Credentials are never stored in a preset. */
202
+ export interface ServerPreset {
203
+ label?: string;
204
+ imap: {
205
+ host: string;
206
+ port?: number;
207
+ secure?: boolean;
208
+ };
209
+ smtp: {
210
+ host: string;
211
+ port?: number;
212
+ secure?: boolean;
213
+ };
214
+ }
215
+ /**
216
+ * Parse the settings-page server presets: a name -> endpoints map, e.g.
217
+ * `corp: { imap: { host: imap.corp }, smtp: { host: smtp.corp } }`.
218
+ * Blank text means "no presets"; a malformed document fails loud, because a
219
+ * silently dropped preset would only resurface later as an unresolvable
220
+ * account reference.
221
+ */
222
+ export declare function parseServerPresets(text: string): Record<string, ServerPreset>;
223
+ /**
224
+ * Serialize a raw accounts mapping (account name -> account config, optionally
225
+ * carrying a defaultAccount key) back into accountsYaml text.
226
+ *
227
+ * Returns '' when no account is left: resolveEmailSettings decides "is the YAML
228
+ * authoritative" with `.trim()`, so an empty list must never become '{}'.
229
+ */
230
+ export declare function serializeAccountsYaml(raw: unknown, defaultAccount?: string): string;
99
231
  /**
100
232
  * Resolve and validate the raw row config. Throws with an actionable message
101
233
  * (in Chinese, since it is what the user and the model both read) when the
102
234
  * account is not fully specified.
103
235
  */
104
236
  export declare function resolveEmailSettings(config: EmailConfig | undefined): ResolvedEmailSettings;
237
+ /** Every name a `provider:` may legally use, built-ins first. */
238
+ export declare function providerNames(custom?: Record<string, ServerPreset>): string[];
239
+ /**
240
+ * The custom preset names in a serverPresets text, best-effort: a malformed
241
+ * text yields no names instead of throwing. Callers use this to answer "may
242
+ * this provider name be written?", where a broken table can only mean "no".
243
+ */
244
+ export declare function presetNamesIn(text: string | undefined): string[];
105
245
  /** v0.1-compatible wrapper: resolve the single (or default) account. */
106
246
  export declare function resolveEmailConfig(config: EmailConfig | undefined): ResolvedEmailConfig;
107
247
  export declare function clampInt(value: unknown, fallback: number, min: number, max: number): number;
package/lib/config.js CHANGED
@@ -1,6 +1,35 @@
1
1
  import { homedir } from 'node:os';
2
- import { parse as parseYaml } from 'yaml';
2
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
3
3
  import { join } from 'node:path';
4
+ /**
5
+ * The provider that authenticates with OAuth2 instead of a password.
6
+ *
7
+ * Microsoft retired basic authentication for Exchange Online, so `outlook` is
8
+ * not a password provider with different endpoints — it is the same endpoints
9
+ * (imap/smtp.office365.com, straight out of PROVIDER_PRESETS) behind a
10
+ * completely different authentication scheme. `authKind` below is that fact.
11
+ */
12
+ export const OUTLOOK_PROVIDER = 'outlook';
13
+ /** The Exchange Online IMAP host. Any account dialling it is an OAuth2 account. */
14
+ export const OUTLOOK_IMAP_HOST = 'outlook.office365.com';
15
+ /**
16
+ * The built-in default client id for the device-code flow: deliberately empty.
17
+ *
18
+ * An earlier revision shipped a registration belonging to a contributor. That
19
+ * cannot be right for a package thousands of strangers install: the Microsoft
20
+ * consent screen would name someone else's application (which enterprise
21
+ * security teams refuse), the sign-in logs and telemetry would land in their
22
+ * tenant along with the user's UPN, and their deleting the app would break
23
+ * every login at once — with an error that only says the client id「可能填错了」,
24
+ * so no user could diagnose it.
25
+ *
26
+ * Nothing third-party is baked in, so an account supplies its own `clientId`
27
+ * (a free Entra public-client registration; the README walks through it) and
28
+ * `startDeviceFlow` refuses with an actionable message until one is set. A
29
+ * maintainer who registers an application for this project restores the
30
+ * out-of-box experience by filling in this one constant.
31
+ */
32
+ export const OUTLOOK_OAUTH2_CLIENT_ID = '';
4
33
  export const PROVIDER_PRESETS = {
5
34
  qq: { imap: { host: 'imap.qq.com', port: 993, secure: true }, smtp: { host: 'smtp.qq.com', port: 465, secure: true } },
6
35
  '163': { imap: { host: 'imap.163.com', port: 993, secure: true }, smtp: { host: 'smtp.163.com', port: 465, secure: true } },
@@ -15,6 +44,20 @@ export const PROVIDER_NAMES = Object.keys(PROVIDER_PRESETS);
15
44
  export const EMAIL_PASSWORD_ENV = 'DSH_EMAIL_PASSWORD';
16
45
  const DEFAULT_MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
17
46
  const DEFAULT_IDLE_TIMEOUT_MS = 60000;
47
+ /**
48
+ * True when an account authenticates with OAuth2 rather than a password.
49
+ *
50
+ * Two ways in, and only these two: the built-in `outlook` provider, or an
51
+ * account pointed at the Exchange Online IMAP host by hand (a custom preset or
52
+ * an explicit `imap.host`). The host test is what keeps a custom preset to
53
+ * outlook.office365.com from silently demanding a password Microsoft no longer
54
+ * accepts — the endpoints are identical, only the credentials are not.
55
+ */
56
+ export function isOAuth2Account(provider, imapHost) {
57
+ if (provider === OUTLOOK_PROVIDER)
58
+ return true;
59
+ return (imapHost ?? '').trim().toLowerCase() === OUTLOOK_IMAP_HOST;
60
+ }
18
61
  export function defaultDownloadDir() {
19
62
  const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
20
63
  return join(home, 'email-downloads');
@@ -46,6 +89,110 @@ export function parseAccountsYaml(text) {
46
89
  }
47
90
  return { map, defaultAccount };
48
91
  }
92
+ /**
93
+ * Parse the settings-page server presets: a name -> endpoints map, e.g.
94
+ * `corp: { imap: { host: imap.corp }, smtp: { host: smtp.corp } }`.
95
+ * Blank text means "no presets"; a malformed document fails loud, because a
96
+ * silently dropped preset would only resurface later as an unresolvable
97
+ * account reference.
98
+ */
99
+ export function parseServerPresets(text) {
100
+ if (text.trim() === '')
101
+ return {};
102
+ let doc;
103
+ try {
104
+ doc = parseYaml(text);
105
+ }
106
+ catch (error) {
107
+ throw new Error('dsh-email:serverPresets 不是合法的 YAML:' + (error instanceof Error ? error.message : String(error)));
108
+ }
109
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
110
+ throw new Error('dsh-email:serverPresets 不是合法的对象映射');
111
+ }
112
+ const presets = {};
113
+ for (const [name, value] of Object.entries(doc)) {
114
+ presets[name] = parseServerPreset(name, value);
115
+ }
116
+ return presets;
117
+ }
118
+ /** Validate one preset entry. Both endpoints are required; port/secure optional. */
119
+ function parseServerPreset(name, value) {
120
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
121
+ throw new Error(`dsh-email:服务器预设 "${name}" 必须是含 imap 与 smtp 的对象`);
122
+ }
123
+ const raw = value;
124
+ if (raw.label !== undefined && typeof raw.label !== 'string') {
125
+ throw new Error(`dsh-email:服务器预设 "${name}" 的 label 必须是字符串`);
126
+ }
127
+ return {
128
+ ...(raw.label !== undefined ? { label: raw.label } : {}),
129
+ imap: parseServerPresetEndpoint(name, 'imap', raw.imap),
130
+ smtp: parseServerPresetEndpoint(name, 'smtp', raw.smtp),
131
+ };
132
+ }
133
+ function parseServerPresetEndpoint(name, kind, value) {
134
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
135
+ throw new Error(`dsh-email:服务器预设 "${name}" 缺少 ${kind}(需为含 host 的对象)`);
136
+ }
137
+ const raw = value;
138
+ const host = typeof raw.host === 'string' ? raw.host.trim() : '';
139
+ if (host === '')
140
+ throw new Error(`dsh-email:服务器预设 "${name}" 的 ${kind}.host 未填写`);
141
+ if (raw.port !== undefined && (typeof raw.port !== 'number' || !Number.isFinite(raw.port))) {
142
+ throw new Error(`dsh-email:服务器预设 "${name}" 的 ${kind}.port 必须是数字`);
143
+ }
144
+ if (raw.secure !== undefined && typeof raw.secure !== 'boolean') {
145
+ throw new Error(`dsh-email:服务器预设 "${name}" 的 ${kind}.secure 必须是布尔值`);
146
+ }
147
+ return {
148
+ host,
149
+ ...(raw.port !== undefined ? { port: raw.port } : {}),
150
+ ...(raw.secure !== undefined ? { secure: raw.secure } : {}),
151
+ };
152
+ }
153
+ /**
154
+ * Serialize a raw accounts mapping (account name -> account config, optionally
155
+ * carrying a defaultAccount key) back into accountsYaml text.
156
+ *
157
+ * Returns '' when no account is left: resolveEmailSettings decides "is the YAML
158
+ * authoritative" with `.trim()`, so an empty list must never become '{}'.
159
+ */
160
+ export function serializeAccountsYaml(raw, defaultAccount) {
161
+ const doc = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
162
+ const accounts = {};
163
+ for (const [name, value] of Object.entries(doc)) {
164
+ if (name === 'defaultAccount')
165
+ continue;
166
+ accounts[name] = normalizeAccountForYaml(value);
167
+ }
168
+ if (Object.keys(accounts).length === 0)
169
+ return '';
170
+ const stored = typeof doc.defaultAccount === 'string' ? doc.defaultAccount.trim() : '';
171
+ const chosen = (defaultAccount ?? '').trim() || stored;
172
+ const out = { ...accounts };
173
+ if (chosen !== '')
174
+ out.defaultAccount = chosen;
175
+ return stringifyYaml(out, { aliasDuplicateObjects: false, lineWidth: 0 });
176
+ }
177
+ /**
178
+ * Prepare one account entry for the YAML writer.
179
+ *
180
+ * `provider` disappears when unset or '' — the settings page uses '' for
181
+ * "custom server", and writing it back would make resolution throw
182
+ * 「provider "" 未知」 (same normalization as toEmailConfig). A numeric
183
+ * password is coerced to a string so the writer quotes it: YAML would
184
+ * otherwise read `password: 123456` back as a number.
185
+ */
186
+ function normalizeAccountForYaml(value) {
187
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
188
+ return value ?? {};
189
+ const account = { ...value };
190
+ if (account.provider === undefined || account.provider === '')
191
+ delete account.provider;
192
+ if (typeof account.password === 'number' || typeof account.password === 'boolean')
193
+ account.password = String(account.password);
194
+ return account;
195
+ }
49
196
  /**
50
197
  * Resolve and validate the raw row config. Throws with an actionable message
51
198
  * (in Chinese, since it is what the user and the model both read) when the
@@ -57,6 +204,8 @@ export function resolveEmailSettings(config) {
57
204
  provider: raw.provider,
58
205
  user: raw.user,
59
206
  password: raw.password,
207
+ clientId: raw.clientId,
208
+ authKind: raw.authKind,
60
209
  imap: raw.imap,
61
210
  smtp: raw.smtp,
62
211
  inboxFolder: raw.inboxFolder,
@@ -65,13 +214,26 @@ export function resolveEmailSettings(config) {
65
214
  const entries = parsedYaml !== undefined
66
215
  ? parsedYaml.map
67
216
  : (raw.accounts === undefined || Object.keys(raw.accounts).length === 0 ? undefined : raw.accounts);
217
+ // The custom preset table is read once, here, and never stored on the result:
218
+ // it is a lookup source for provider names, not part of the resolved config.
219
+ // A broken preset text degrades to "no custom presets" — the account-level
220
+ // error below stays explicit about which name could not be resolved.
221
+ let customPresets = {};
222
+ try {
223
+ customPresets = parseServerPresets(raw.serverPresets ?? '');
224
+ }
225
+ catch {
226
+ customPresets = {};
227
+ }
228
+ const providers = providerTable(customPresets);
229
+ const known = providerNames(customPresets);
68
230
  const accounts = new Map();
69
231
  if (entries === undefined) {
70
- accounts.set('default', resolveAccount('default', common, {}, true));
232
+ accounts.set('default', resolveAccount('default', common, {}, true, providers, known));
71
233
  }
72
234
  else {
73
235
  for (const [name, acc] of Object.entries(entries)) {
74
- accounts.set(name, resolveAccount(name, common, acc ?? {}, false));
236
+ accounts.set(name, resolveAccount(name, common, acc ?? {}, false, providers, known));
75
237
  }
76
238
  }
77
239
  let defaultName;
@@ -106,11 +268,52 @@ export function resolveEmailSettings(config) {
106
268
  bodySearchLimit: clampInt(raw.bodySearchLimit, 30, 5, 200),
107
269
  };
108
270
  }
271
+ /**
272
+ * The provider lookup order: the 8 built-ins first, then the custom presets.
273
+ * A built-in name always wins, so a custom preset cannot shadow `qq` — and an
274
+ * inherited Object member (`constructor`, `toString`) is never a provider.
275
+ *
276
+ * The table has a null prototype on purpose: a plain `{}` answers
277
+ * `table['constructor']` with Object's own constructor through the prototype
278
+ * chain, which would be mistaken for a preset and then blow up on `.imap.host`.
279
+ */
280
+ function providerTable(custom) {
281
+ const table = Object.create(null);
282
+ for (const [name, preset] of Object.entries(PROVIDER_PRESETS))
283
+ table[name] = preset;
284
+ for (const [name, preset] of Object.entries(custom)) {
285
+ if (table[name] === undefined)
286
+ table[name] = preset;
287
+ }
288
+ return table;
289
+ }
290
+ /** Every name a `provider:` may legally use, built-ins first. */
291
+ export function providerNames(custom = {}) {
292
+ const names = [...PROVIDER_NAMES];
293
+ for (const name of Object.keys(custom))
294
+ if (!names.includes(name))
295
+ names.push(name);
296
+ return names;
297
+ }
298
+ /**
299
+ * The custom preset names in a serverPresets text, best-effort: a malformed
300
+ * text yields no names instead of throwing. Callers use this to answer "may
301
+ * this provider name be written?", where a broken table can only mean "no".
302
+ */
303
+ export function presetNamesIn(text) {
304
+ try {
305
+ return Object.keys(parseServerPresets(text ?? ''));
306
+ }
307
+ catch {
308
+ return [];
309
+ }
310
+ }
109
311
  /** Merge one account over the shared shorthand and validate it. */
110
- function resolveAccount(name, common, acc, allowEnvPassword) {
111
- const preset = acc.provider === undefined ? PROVIDER_PRESETS[common.provider ?? ''] : PROVIDER_PRESETS[acc.provider];
112
- if ((acc.provider ?? common.provider) !== undefined && preset === undefined) {
113
- throw new Error(`dsh-email:账号 "${name}" 的 provider "${acc.provider ?? common.provider}" 未知,可选:${PROVIDER_NAMES.join('/')};或省略 provider 直接填 imap.host 与 smtp.host`);
312
+ function resolveAccount(name, common, acc, allowEnvPassword, providers, known) {
313
+ const requested = acc.provider ?? common.provider;
314
+ const preset = requested === undefined ? undefined : providers[requested];
315
+ if (requested !== undefined && preset === undefined) {
316
+ throw new Error(`dsh-email:账号 "${name}" 的 provider "${requested}" 未知,可选:${known.join('/')};或省略 provider 直接填 imap.host 与 smtp.host`);
114
317
  }
115
318
  const user = (acc.user ?? common.user ?? '').trim();
116
319
  // The settings form uses '' for an empty password. In single-account mode
@@ -131,18 +334,36 @@ function resolveAccount(name, common, acc, allowEnvPassword) {
131
334
  const problems = [];
132
335
  if (user === '')
133
336
  problems.push(`账号 "${name}" 的 user(邮箱地址)未填写`);
134
- if (password === '')
337
+ // An explicit `authKind` outranks the derivation: a tenant that still accepts
338
+ // an app password for Exchange Online, or a mailbox that worked before the
339
+ // derivation existed, keeps working instead of being told「尚未登录」.
340
+ const forcedAuthKind = String(acc.authKind ?? common.authKind ?? '').trim().toLowerCase();
341
+ if (forcedAuthKind !== '' && forcedAuthKind !== 'oauth2' && forcedAuthKind !== 'password') {
342
+ problems.push(`账号 "${name}" 的 authKind 只能是 oauth2 或 password,当前是 "${forcedAuthKind}"`);
343
+ }
344
+ const oauth2 = forcedAuthKind === 'password'
345
+ ? false
346
+ : forcedAuthKind === 'oauth2' || isOAuth2Account(requested, imap.host);
347
+ // An OAuth2 account has no password on purpose: its credential is the token
348
+ // in the OAuth2 store, and requiring a password would demand a secret
349
+ // Microsoft no longer accepts for Exchange Online.
350
+ if (!oauth2 && password === '')
135
351
  problems.push(`账号 "${name}" 的 password 未填写(单账号可用环境变量 ${EMAIL_PASSWORD_ENV})`);
136
352
  if (imap.host === undefined || imap.host === '')
137
- problems.push(`账号 "${name}" 的 imap.host 未填写(可填 provider 预设:${PROVIDER_NAMES.join('/')})`);
353
+ problems.push(`账号 "${name}" 的 imap.host 未填写(可填 provider 预设:${known.join('/')})`);
138
354
  if (smtp.host === undefined || smtp.host === '')
139
355
  problems.push(`账号 "${name}" 的 smtp.host 未填写(同上)`);
140
356
  if (problems.length > 0) {
141
357
  throw new Error(`dsh-email 未配置:${problems.join(';')}。请在 profile 的 cordis.patch.yml 中覆盖 tool-email 行并重启(见插件 README)`);
142
358
  }
359
+ const clientId = (acc.clientId ?? common.clientId ?? '').trim();
143
360
  return {
144
361
  user,
145
- password,
362
+ // An OAuth2 account never carries a password: a stale one left in the YAML
363
+ // from before the provider changed must not travel into the pool.
364
+ password: oauth2 ? '' : password,
365
+ authKind: oauth2 ? 'oauth2' : 'password',
366
+ ...(clientId !== '' ? { clientId } : {}),
146
367
  imap: { ...imap, host: imap.host, port: imap.port, secure: imap.secure },
147
368
  smtp: { ...smtp, host: smtp.host, port: smtp.port, secure: smtp.secure },
148
369
  inboxFolder: (acc.inboxFolder ?? common.inboxFolder ?? '').trim() || 'INBOX',
package/lib/index.d.ts CHANGED
@@ -4,9 +4,13 @@ export declare const inject: string[];
4
4
  export type Config = EmailConfig;
5
5
  /** Compose settings/pool lifecycle, tools, browser routes and the outgoing-mail gate. */
6
6
  export declare function apply(ctx: any, config?: Config): void;
7
- export { clampInt, defaultDownloadDir, EMAIL_PASSWORD_ENV, parseAccountsYaml, PROVIDER_NAMES, resolveEmailConfig, resolveEmailSettings } from './config.js';
8
- export { buildReplyMessage, EmailPool, extractMessageIds, MailError, messageMatchesQuery, messageOf, selectAttachmentPart, validateAttachmentPaths } from './mail-client.js';
7
+ export { clampInt, defaultDownloadDir, EMAIL_PASSWORD_ENV, isOAuth2Account, OUTLOOK_OAUTH2_CLIENT_ID, OUTLOOK_PROVIDER, parseAccountsYaml, parseServerPresets, presetNamesIn, providerNames, PROVIDER_NAMES, resolveEmailConfig, resolveEmailSettings, serializeAccountsYaml } from './config.js';
8
+ export type { AuthKind, ResolvedEmailConfig } from './config.js';
9
+ export { ACCESS_TOKEN_MARGIN_MS, classifyOAuthFailure, clearTokenFor, DEVICE_CODE_URL, getFreshAccessToken, mapAadstsMessage, NO_CLIENT_ID_MESSAGE, NOT_LOGGED_IN_MESSAGE, oauth2StateOf, oauth2TokenFile, OAUTH2_SCOPES, OAuth2Error, pollDeviceFlow, readTokenStore, startDeviceFlow, TOKEN_URL, writeTokenStore, } from './oauth2.js';
10
+ export type { DeviceFlowStart, OAuth2PollResult, OAuth2State, OAuth2TokenEntry, OAuth2TokenStore } from './oauth2.js';
11
+ export { buildReplyMessage, EmailPool, extractMessageIds, imapAuthOf, looksLikeAuthFailure, MailError, messageMatchesQuery, messageOf, OAUTH2_RELOGIN_MESSAGE, redactCredentials, selectAttachmentPart, smtpAuthOf, validateAttachmentPaths } from './mail-client.js';
9
12
  export { flattenAddresses, parseRawMessage, sanitizeFilename, stripHtml, truncateText } from './parse.js';
10
13
  export { EmailSettingsSchema, SETTINGS_NAMESPACE, toEmailConfig, toSettingsBase, validateSettingsValue } from './settings.js';
11
14
  export { parseEmailDay } from './tool-contract.js';
12
15
  export { EmailSettingsBackend, installEmailSettingsWeb, SETTINGS_ROUTE } from './web.js';
16
+ export type { AccountCardData, AccountCardInput } from './web.js';
package/lib/index.js CHANGED
@@ -14,8 +14,9 @@ export function apply(ctx, config = {}) {
14
14
  ctx.tools.register(definition);
15
15
  installSendApproval(ctx, runtime);
16
16
  }
17
- export { clampInt, defaultDownloadDir, EMAIL_PASSWORD_ENV, parseAccountsYaml, PROVIDER_NAMES, resolveEmailConfig, resolveEmailSettings } from './config.js';
18
- export { buildReplyMessage, EmailPool, extractMessageIds, MailError, messageMatchesQuery, messageOf, selectAttachmentPart, validateAttachmentPaths } from './mail-client.js';
17
+ export { clampInt, defaultDownloadDir, EMAIL_PASSWORD_ENV, isOAuth2Account, OUTLOOK_OAUTH2_CLIENT_ID, OUTLOOK_PROVIDER, parseAccountsYaml, parseServerPresets, presetNamesIn, providerNames, PROVIDER_NAMES, resolveEmailConfig, resolveEmailSettings, serializeAccountsYaml } from './config.js';
18
+ export { ACCESS_TOKEN_MARGIN_MS, classifyOAuthFailure, clearTokenFor, DEVICE_CODE_URL, getFreshAccessToken, mapAadstsMessage, NO_CLIENT_ID_MESSAGE, NOT_LOGGED_IN_MESSAGE, oauth2StateOf, oauth2TokenFile, OAUTH2_SCOPES, OAuth2Error, pollDeviceFlow, readTokenStore, startDeviceFlow, TOKEN_URL, writeTokenStore, } from './oauth2.js';
19
+ export { buildReplyMessage, EmailPool, extractMessageIds, imapAuthOf, looksLikeAuthFailure, MailError, messageMatchesQuery, messageOf, OAUTH2_RELOGIN_MESSAGE, redactCredentials, selectAttachmentPart, smtpAuthOf, validateAttachmentPaths } from './mail-client.js';
19
20
  export { flattenAddresses, parseRawMessage, sanitizeFilename, stripHtml, truncateText } from './parse.js';
20
21
  export { EmailSettingsSchema, SETTINGS_NAMESPACE, toEmailConfig, toSettingsBase, validateSettingsValue } from './settings.js';
21
22
  export { parseEmailDay } from './tool-contract.js';
@@ -5,6 +5,62 @@ export declare class MailError extends Error {
5
5
  constructor(message: string);
6
6
  }
7
7
  export declare function messageOf(error: unknown, fallback: string): string;
8
+ /**
9
+ * Replace anything credential-shaped in a server's own error text before it
10
+ * reaches a user.
11
+ *
12
+ * IMAP and SMTP servers routinely quote back the authentication string they
13
+ * rejected. For XOAUTH2 that string is `user=…\x01auth=Bearer <token>\x01\x01`,
14
+ * usually base64'd — so the raw message carries a live access token, and these
15
+ * messages are rendered in the settings panel, returned by the mail tools, and
16
+ * pasted into bug reports.
17
+ *
18
+ * Two shapes are masked: a JWT (three base64url segments, which is what every
19
+ * OAuth2 access token looks like) and a long base64 run (the quoted XOAUTH2
20
+ * blob). The replacement keeps the length so a report still says how big the
21
+ * thing was, without saying what it was.
22
+ */
23
+ export declare function redactCredentials(text: string): string;
24
+ /** The IMAP auth shape imapflow accepts: a password, or an OAuth2 access token. */
25
+ export interface ImapAuth {
26
+ user: string;
27
+ pass?: string;
28
+ accessToken?: string;
29
+ }
30
+ /**
31
+ * The SMTP auth shape nodemailer accepts. `type` is the literal union
32
+ * nodemailer's typings model, not a loose string: anything wider makes the
33
+ * whole transport options object fail to match and silently degrades the type.
34
+ */
35
+ export type SmtpAuth = {
36
+ user: string;
37
+ pass: string;
38
+ } | {
39
+ type: 'OAuth2';
40
+ user: string;
41
+ accessToken: string;
42
+ };
43
+ /**
44
+ * The IMAP `auth` block for one account. Pure so the shape the library
45
+ * receives is testable without a socket: an OAuth2 account authenticates with
46
+ * `accessToken` (imapflow then runs AUTHENTICATE XOAUTH2) and a password
47
+ * account with `pass`, exactly as before.
48
+ */
49
+ export declare function imapAuthOf(cfg: Pick<ResolvedEmailConfig, 'user' | 'password' | 'authKind'>, accessToken?: string): ImapAuth;
50
+ /**
51
+ * Nodemailer consumes an OAuth2 token through accessToken, not pass.
52
+ * Refresh remains owned by this plugin; no refresh credentials leave here.
53
+ */
54
+ export declare function smtpAuthOf(cfg: Pick<ResolvedEmailConfig, 'user' | 'password' | 'authKind'>, accessToken?: string): SmtpAuth;
55
+ /** The message an OAuth2 account gets when the mailbox has to be logged into again. */
56
+ export declare const OAUTH2_RELOGIN_MESSAGE = "\u90AE\u7BB1\u767B\u5F55\u5931\u8D25\uFF1A\u8BF7\u5230\u8BBE\u7F6E\u9875\u91CD\u65B0\u767B\u5F55\uFF08Microsoft \u8D26\u53F7\u4F7F\u7528\u8BBE\u5907\u7801\u767B\u5F55\uFF0C\u4E0D\u4F7F\u7528\u6388\u6743\u7801\uFF09";
57
+ /**
58
+ * True for the errors both libraries report when the server rejects the
59
+ * credentials. An expired access token is indistinguishable from a wrong
60
+ * password at this level, so the connection retries once with a forced refresh
61
+ * before it believes the token is really dead.
62
+ */
63
+ export declare function looksLikeAuthFailure(error: unknown): boolean;
8
64
  interface AttachmentPart {
9
65
  part: string;
10
66
  filename: string;
@@ -68,17 +124,50 @@ export declare class EmailPool {
68
124
  private enqueue;
69
125
  withImap<T>(accountName: string | undefined, folder: string | null, run: (client: ImapFlow) => Promise<T>, readOnly?: boolean, signal?: AbortSignal): Promise<T>;
70
126
  private createImap;
127
+ /**
128
+ * Dial and authenticate one fresh IMAP connection.
129
+ *
130
+ * A password account connects once. An OAuth2 account connects with a fresh
131
+ * access token and, when the server rejects it, refreshes once and tries
132
+ * again: a token that expired between the freshness check and the dial is
133
+ * indistinguishable from a wrong password at the socket, and guessing wrong
134
+ * would send the user through a browser login for nothing.
135
+ */
136
+ private connectImap;
137
+ /** The token store's own errors are already actionable; never dress them as IMAP failures. */
138
+ private oauth2ErrorOf;
71
139
  private imapRun;
72
140
  private normalizeImapError;
73
141
  private evictImap;
74
142
  /** Reap IMAP connections idle for longer than idleTimeoutMs. */
75
143
  startIdleSweep(): void;
76
144
  dispose(): void;
145
+ /**
146
+ * A pooled transporter for one account. The token is captured when the
147
+ * transporter is built; an OAuth2 token that turns out to be stale is
148
+ * re-minted in sendMail, which rebuilds the transporter.
149
+ */
77
150
  private transporter;
78
- /** Send through the pooled transporter while making cancellation close it. */
151
+ private dropTransporter;
152
+ /**
153
+ * Send through the pooled transporter while making cancellation close it.
154
+ *
155
+ * An OAuth2 transporter carries a token that was minted when it was built,
156
+ * so a rejection is retried once against a freshly built one (and a fresh
157
+ * form of whatever stored token state exists). Password accounts keep the
158
+ * single attempt they always had.
159
+ */
79
160
  private sendMail;
80
161
  list(accountName: string | undefined, folder: string, limit: number, offset: number, unreadOnly: boolean, since?: Date, until?: Date, signal?: AbortSignal): Promise<EmailListResult>;
81
162
  search(accountName: string | undefined, query: string, folder: string, limit: number, since?: Date, until?: Date, signal?: AbortSignal): Promise<EmailSearchResult>;
163
+ /**
164
+ * Confirm server-side hits against the mailbox itself: fetch the envelopes
165
+ * of the newest candidates — the same window the body-scan fallback looks at
166
+ * — and keep only those that really carry the query in subject/from/to/cc,
167
+ * the four fields the server was asked about. No body is downloaded here,
168
+ * and uids the server made up simply return nothing.
169
+ */
170
+ private searchHits;
82
171
  /** Client-side scan of the tail of the mailbox, newest first. */
83
172
  private searchBodies;
84
173
  private fetchListed;