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/README.en.md +86 -8
- package/README.md +66 -9
- package/lib/client.js +2344 -191
- package/lib/config.d.ts +161 -1
- package/lib/config.js +244 -11
- package/lib/index.d.ts +6 -2
- package/lib/index.js +3 -2
- package/lib/mail-client.d.ts +102 -3
- package/lib/mail-client.js +318 -73
- package/lib/oauth2.d.ts +124 -0
- package/lib/oauth2.js +417 -0
- package/lib/runtime.js +28 -8
- package/lib/settings.d.ts +22 -1
- package/lib/settings.js +122 -34
- package/lib/tool-contract.d.ts +3 -0
- package/lib/tool-contract.js +11 -2
- package/lib/tools.js +22 -6
- package/lib/types.d.ts +7 -0
- package/lib/web.d.ts +209 -2
- package/lib/web.js +917 -14
- package/package.json +1 -1
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,39 @@ 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
|
|
57
|
+
/** Built-in provider name, or a custom serverPresets name. */
|
|
58
|
+
provider?: ProviderRef;
|
|
17
59
|
user?: string;
|
|
18
60
|
password?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Display name for the From header. The address stays `user` — recipients
|
|
63
|
+
* must see the mailbox that owns the mail, not the login.
|
|
64
|
+
*/
|
|
65
|
+
senderName?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Login handed to IMAP/SMTP when it differs from `user`: the alias case,
|
|
68
|
+
* where `user` is the address mail is sent *from* and the server only
|
|
69
|
+
* authenticates the real account, or a relay whose login is not a mailbox
|
|
70
|
+
* at all. Defaults to `user`.
|
|
71
|
+
*/
|
|
72
|
+
authUser?: string;
|
|
73
|
+
/** Password that goes with `authUser`. Defaults to `password`. */
|
|
74
|
+
authPassword?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Public-client id used by the OAuth2 device-code flow. Only read for an
|
|
77
|
+
* OAuth2 account, where it overrides OUTLOOK_OAUTH2_CLIENT_ID.
|
|
78
|
+
*/
|
|
79
|
+
clientId?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Escape hatch over the derived authentication scheme. Left unset, an account
|
|
82
|
+
* pointed at the `outlook` provider or the Exchange Online IMAP host is an
|
|
83
|
+
* OAuth2 account and its password is dropped. Set `password` to keep using an
|
|
84
|
+
* app password there — a tenant that still accepts basic auth (hybrid or
|
|
85
|
+
* on-prem, SMTP AUTH left enabled), or a mailbox that worked before this
|
|
86
|
+
* derivation existed, must not be told「尚未登录」after an upgrade. Set
|
|
87
|
+
* `oauth2` to opt in from a custom host.
|
|
88
|
+
*/
|
|
89
|
+
authKind?: AuthKind;
|
|
19
90
|
imap?: ImapConfig;
|
|
20
91
|
smtp?: SmtpConfig;
|
|
21
92
|
inboxFolder?: string;
|
|
@@ -29,6 +100,12 @@ export interface EmailConfig extends AccountConfig {
|
|
|
29
100
|
accounts?: Record<string, AccountConfig>;
|
|
30
101
|
/** YAML text of the accounts map, editable from the settings page. Wins over accounts when non-empty. */
|
|
31
102
|
accountsYaml?: string;
|
|
103
|
+
/**
|
|
104
|
+
* YAML text of the reusable server presets (connection endpoints only).
|
|
105
|
+
* Deliberately never part of ResolvedEmailSettings: editing a preset must not
|
|
106
|
+
* change the pool fingerprint and tear down live IMAP connections.
|
|
107
|
+
*/
|
|
108
|
+
serverPresets?: string;
|
|
32
109
|
/** Which account tools use when the call omits account. Required with 2+ accounts. */
|
|
33
110
|
defaultAccount?: string;
|
|
34
111
|
/** Directory email_attachment writes into. Default: the session workspace's .dsh-email-downloads (falls back to $DSH_HOME/email-downloads). */
|
|
@@ -54,13 +131,48 @@ export interface ProviderPreset {
|
|
|
54
131
|
secure: boolean;
|
|
55
132
|
};
|
|
56
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Anything that can stand in for a provider: a built-in preset, or a custom
|
|
136
|
+
* `serverPresets` entry (whose port/secure are optional and whose label is
|
|
137
|
+
* editor-facing only). Both are looked up the same way.
|
|
138
|
+
*/
|
|
139
|
+
export interface EndpointPreset {
|
|
140
|
+
imap: {
|
|
141
|
+
host: string;
|
|
142
|
+
port?: number;
|
|
143
|
+
secure?: boolean;
|
|
144
|
+
};
|
|
145
|
+
smtp: {
|
|
146
|
+
host: string;
|
|
147
|
+
port?: number;
|
|
148
|
+
secure?: boolean;
|
|
149
|
+
};
|
|
150
|
+
label?: string;
|
|
151
|
+
}
|
|
57
152
|
export declare const PROVIDER_PRESETS: Record<string, ProviderPreset>;
|
|
58
153
|
export declare const PROVIDER_NAMES: string[];
|
|
59
154
|
export declare const EMAIL_PASSWORD_ENV = "DSH_EMAIL_PASSWORD";
|
|
60
155
|
/** Fully resolved, validated configuration for one account. */
|
|
61
156
|
export interface ResolvedEmailConfig {
|
|
62
157
|
user: string;
|
|
158
|
+
/** Display name for the From header, '' when the account does not set one. */
|
|
159
|
+
senderName: string;
|
|
160
|
+
/** Login actually handed to IMAP/SMTP (== user unless authUser is set). */
|
|
161
|
+
authUser: string;
|
|
162
|
+
/** Password for authUser (== password unless authPassword is set). */
|
|
163
|
+
authPassword: string;
|
|
164
|
+
/**
|
|
165
|
+
* The app password / 授权码. Empty for an OAuth2 account — that is the point:
|
|
166
|
+
* nothing is stored, the token store holds the credential instead.
|
|
167
|
+
*/
|
|
63
168
|
password: string;
|
|
169
|
+
/**
|
|
170
|
+
* How this account authenticates. Derived from `provider` / the IMAP host
|
|
171
|
+
* unless the account pins it with an explicit `authKind`.
|
|
172
|
+
*/
|
|
173
|
+
authKind: AuthKind;
|
|
174
|
+
/** Public-client id for the device-code flow (OAuth2 accounts only). */
|
|
175
|
+
clientId?: string;
|
|
64
176
|
imap: ImapConfig & {
|
|
65
177
|
host: string;
|
|
66
178
|
port: number;
|
|
@@ -73,6 +185,16 @@ export interface ResolvedEmailConfig {
|
|
|
73
185
|
};
|
|
74
186
|
inboxFolder: string;
|
|
75
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* True when an account authenticates with OAuth2 rather than a password.
|
|
190
|
+
*
|
|
191
|
+
* Two ways in, and only these two: the built-in `outlook` provider, or an
|
|
192
|
+
* account pointed at the Exchange Online IMAP host by hand (a custom preset or
|
|
193
|
+
* an explicit `imap.host`). The host test is what keeps a custom preset to
|
|
194
|
+
* outlook.office365.com from silently demanding a password Microsoft no longer
|
|
195
|
+
* accepts — the endpoints are identical, only the credentials are not.
|
|
196
|
+
*/
|
|
197
|
+
export declare function isOAuth2Account(provider: string | undefined, imapHost: string | undefined): boolean;
|
|
76
198
|
/** Fully resolved plugin settings: the account map plus shared policy. */
|
|
77
199
|
export interface ResolvedEmailSettings {
|
|
78
200
|
accounts: Map<string, ResolvedEmailConfig>;
|
|
@@ -96,12 +218,50 @@ export declare function parseAccountsYaml(text: string): {
|
|
|
96
218
|
map: Record<string, AccountConfig>;
|
|
97
219
|
defaultAccount?: string;
|
|
98
220
|
};
|
|
221
|
+
/** Reusable IMAP/SMTP endpoints. Credentials are never stored in a preset. */
|
|
222
|
+
export interface ServerPreset {
|
|
223
|
+
label?: string;
|
|
224
|
+
imap: {
|
|
225
|
+
host: string;
|
|
226
|
+
port?: number;
|
|
227
|
+
secure?: boolean;
|
|
228
|
+
};
|
|
229
|
+
smtp: {
|
|
230
|
+
host: string;
|
|
231
|
+
port?: number;
|
|
232
|
+
secure?: boolean;
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Parse the settings-page server presets: a name -> endpoints map, e.g.
|
|
237
|
+
* `corp: { imap: { host: imap.corp }, smtp: { host: smtp.corp } }`.
|
|
238
|
+
* Blank text means "no presets"; a malformed document fails loud, because a
|
|
239
|
+
* silently dropped preset would only resurface later as an unresolvable
|
|
240
|
+
* account reference.
|
|
241
|
+
*/
|
|
242
|
+
export declare function parseServerPresets(text: string): Record<string, ServerPreset>;
|
|
243
|
+
/**
|
|
244
|
+
* Serialize a raw accounts mapping (account name -> account config, optionally
|
|
245
|
+
* carrying a defaultAccount key) back into accountsYaml text.
|
|
246
|
+
*
|
|
247
|
+
* Returns '' when no account is left: resolveEmailSettings decides "is the YAML
|
|
248
|
+
* authoritative" with `.trim()`, so an empty list must never become '{}'.
|
|
249
|
+
*/
|
|
250
|
+
export declare function serializeAccountsYaml(raw: unknown, defaultAccount?: string): string;
|
|
99
251
|
/**
|
|
100
252
|
* Resolve and validate the raw row config. Throws with an actionable message
|
|
101
253
|
* (in Chinese, since it is what the user and the model both read) when the
|
|
102
254
|
* account is not fully specified.
|
|
103
255
|
*/
|
|
104
256
|
export declare function resolveEmailSettings(config: EmailConfig | undefined): ResolvedEmailSettings;
|
|
257
|
+
/** Every name a `provider:` may legally use, built-ins first. */
|
|
258
|
+
export declare function providerNames(custom?: Record<string, ServerPreset>): string[];
|
|
259
|
+
/**
|
|
260
|
+
* The custom preset names in a serverPresets text, best-effort: a malformed
|
|
261
|
+
* text yields no names instead of throwing. Callers use this to answer "may
|
|
262
|
+
* this provider name be written?", where a broken table can only mean "no".
|
|
263
|
+
*/
|
|
264
|
+
export declare function presetNamesIn(text: string | undefined): string[];
|
|
105
265
|
/** v0.1-compatible wrapper: resolve the single (or default) account. */
|
|
106
266
|
export declare function resolveEmailConfig(config: EmailConfig | undefined): ResolvedEmailConfig;
|
|
107
267
|
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,16 +268,63 @@ 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
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
117
320
|
// that explicitly selects the environment fallback; named accounts stay isolated.
|
|
118
321
|
const password = (acc.password ?? common.password) || (allowEnvPassword ? process.env[EMAIL_PASSWORD_ENV] ?? '' : '');
|
|
322
|
+
// An alias account sends from `user` but authenticates as somebody else, and a
|
|
323
|
+
// relay may use a different password than the mailbox it delivers for. Both
|
|
324
|
+
// default to the single-account pair so nothing changes for existing setups.
|
|
325
|
+
const authUser = (acc.authUser ?? common.authUser ?? '').trim() || user;
|
|
326
|
+
const authPassword = (acc.authPassword ?? common.authPassword) || password;
|
|
327
|
+
const senderName = (acc.senderName ?? common.senderName ?? '').trim();
|
|
119
328
|
const imap = {
|
|
120
329
|
host: acc.imap?.host ?? common.imap?.host ?? preset?.imap.host,
|
|
121
330
|
port: acc.imap?.port ?? common.imap?.port ?? preset?.imap.port,
|
|
@@ -131,18 +340,42 @@ function resolveAccount(name, common, acc, allowEnvPassword) {
|
|
|
131
340
|
const problems = [];
|
|
132
341
|
if (user === '')
|
|
133
342
|
problems.push(`账号 "${name}" 的 user(邮箱地址)未填写`);
|
|
134
|
-
|
|
135
|
-
|
|
343
|
+
// An explicit `authKind` outranks the derivation: a tenant that still accepts
|
|
344
|
+
// an app password for Exchange Online, or a mailbox that worked before the
|
|
345
|
+
// derivation existed, keeps working instead of being told「尚未登录」.
|
|
346
|
+
const forcedAuthKind = String(acc.authKind ?? common.authKind ?? '').trim().toLowerCase();
|
|
347
|
+
if (forcedAuthKind !== '' && forcedAuthKind !== 'oauth2' && forcedAuthKind !== 'password') {
|
|
348
|
+
problems.push(`账号 "${name}" 的 authKind 只能是 oauth2 或 password,当前是 "${forcedAuthKind}"`);
|
|
349
|
+
}
|
|
350
|
+
const oauth2 = forcedAuthKind === 'password'
|
|
351
|
+
? false
|
|
352
|
+
: forcedAuthKind === 'oauth2' || isOAuth2Account(requested, imap.host);
|
|
353
|
+
// An OAuth2 account has no password on purpose: its credential is the token
|
|
354
|
+
// in the OAuth2 store, and requiring a password would demand a secret
|
|
355
|
+
// Microsoft no longer accepts for Exchange Online.
|
|
356
|
+
if (!oauth2 && authPassword === '') {
|
|
357
|
+
problems.push(`账号 "${name}" 的 ${authUser === user ? 'password' : 'authPassword'} 未填写(单账号可用环境变量 ${EMAIL_PASSWORD_ENV})`);
|
|
358
|
+
}
|
|
136
359
|
if (imap.host === undefined || imap.host === '')
|
|
137
|
-
problems.push(`账号 "${name}" 的 imap.host 未填写(可填 provider 预设:${
|
|
360
|
+
problems.push(`账号 "${name}" 的 imap.host 未填写(可填 provider 预设:${known.join('/')})`);
|
|
138
361
|
if (smtp.host === undefined || smtp.host === '')
|
|
139
362
|
problems.push(`账号 "${name}" 的 smtp.host 未填写(同上)`);
|
|
140
363
|
if (problems.length > 0) {
|
|
141
364
|
throw new Error(`dsh-email 未配置:${problems.join(';')}。请在 profile 的 cordis.patch.yml 中覆盖 tool-email 行并重启(见插件 README)`);
|
|
142
365
|
}
|
|
366
|
+
const clientId = (acc.clientId ?? common.clientId ?? '').trim();
|
|
143
367
|
return {
|
|
144
368
|
user,
|
|
145
|
-
|
|
369
|
+
senderName,
|
|
370
|
+
// OAuth2 logs in with the token's own account, so the alias login pair only
|
|
371
|
+
// exists for password accounts; and an OAuth2 account never carries a
|
|
372
|
+
// password at all — a stale one left in the YAML from before the provider
|
|
373
|
+
// changed must not travel into the pool.
|
|
374
|
+
authUser: oauth2 ? user : authUser,
|
|
375
|
+
authPassword: oauth2 ? '' : authPassword,
|
|
376
|
+
password: oauth2 ? '' : password,
|
|
377
|
+
authKind: oauth2 ? 'oauth2' : 'password',
|
|
378
|
+
...(clientId !== '' ? { clientId } : {}),
|
|
146
379
|
imap: { ...imap, host: imap.host, port: imap.port, secure: imap.secure },
|
|
147
380
|
smtp: { ...smtp, host: smtp.host, port: smtp.port, secure: smtp.secure },
|
|
148
381
|
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 {
|
|
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 {
|
|
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';
|