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/settings.js CHANGED
@@ -16,6 +16,10 @@ export const EmailSettingsSchema = z.object({
16
16
  maxBodyChars: z.number().default(20000),
17
17
  downloadDir: z.string().default(''),
18
18
  accountsYaml: z.string().role('secret').default(''),
19
+ // Reusable IMAP/SMTP endpoints for the account cards. No credentials, so no
20
+ // role('secret') — and deliberately not projected into EmailConfig, or
21
+ // editing a preset would change the pool fingerprint and drop live sessions.
22
+ serverPresets: z.string().default(''),
19
23
  imap: z.object({
20
24
  host: z.string().default(''),
21
25
  port: z.number().default(993),
@@ -37,6 +41,7 @@ export function toSettingsBase(config) {
37
41
  ...(config.sendApproval !== undefined ? { sendApproval: config.sendApproval } : {}),
38
42
  ...(config.maxBodyChars !== undefined ? { maxBodyChars: config.maxBodyChars } : {}),
39
43
  ...(config.downloadDir !== undefined && config.downloadDir !== '' ? { downloadDir: config.downloadDir } : {}),
44
+ ...(config.serverPresets !== undefined && config.serverPresets !== '' ? { serverPresets: config.serverPresets } : {}),
40
45
  ...(config.imap !== undefined ? {
41
46
  imap: {
42
47
  host: config.imap.host ?? '',
@@ -53,6 +58,31 @@ export function toSettingsBase(config) {
53
58
  } : {}),
54
59
  };
55
60
  }
61
+ /** True for a field the draft really carries: absent, undefined and null all mean 「未设置」. */
62
+ function isSet(field) {
63
+ return field !== undefined && field !== null;
64
+ }
65
+ /**
66
+ * The port an endpoint object carries, or undefined when the endpoint is absent
67
+ * or carries no port key.
68
+ */
69
+ function endpointPort(endpoint) {
70
+ if (endpoint === null || typeof endpoint !== 'object' || Array.isArray(endpoint))
71
+ return undefined;
72
+ return endpoint.port;
73
+ }
74
+ /**
75
+ * Is this port outside 1-65535? A missing port is not a violation (「未设置」),
76
+ * but anything present is compared loosely — exactly the `<`/`>` coercion the
77
+ * previous `value.imap.port < 1` used, so a numeric string is still judged
78
+ * rather than waved through.
79
+ */
80
+ function portOutOfRange(port) {
81
+ if (!isSet(port))
82
+ return false;
83
+ const n = port;
84
+ return n < 1 || n > 65535;
85
+ }
56
86
  /**
57
87
  * Project a settings value back into EmailConfig shape.
58
88
  *
@@ -60,65 +90,123 @@ export function toSettingsBase(config) {
60
90
  * are projected, so schema defaults never shadow the row config or the
61
91
  * provider presets (choosing outlook must NOT force smtp port 465 over the
62
92
  * preset's 587). Pass `null` to project every field (draft paths).
93
+ *
94
+ * The value may also be *partial*: the settings page posts a draft, and the card
95
+ * editor posts its own control bundle, from which JSON.stringify drops every key
96
+ * it does not model — `provider` above all. A field the draft does not carry
97
+ * means 「未设置」 and must be left out entirely: assigning `out.provider =
98
+ * undefined` is NOT the same as omitting it, because an own key holding undefined
99
+ * still wins in `{ ...rowConfig, ...toEmailConfig(value, null) }` and would erase
100
+ * the row's provider (that produced 「未知的邮件服务商 undefined」 on a card whose
101
+ * provider was plainly selected). Same normalization as toSettingsBase.
63
102
  */
64
103
  export function toEmailConfig(value, user) {
104
+ const draft = (value ?? {});
65
105
  const has = (key) => user === null || user?.[key] !== undefined;
66
106
  const out = {};
67
- if (has('provider'))
68
- out.provider = value.provider === '' ? undefined : value.provider;
107
+ const set = (key, field) => {
108
+ if (isSet(field))
109
+ out[key] = field;
110
+ };
111
+ // provider is the one field where '' is a *value* rather than an absence: the
112
+ // settings page uses it for 「自定义服务器」, so it must still clear the row
113
+ // provider even though every other unset field is now omitted.
114
+ if (has('provider') && isSet(draft.provider)) {
115
+ out.provider = draft.provider === '' ? undefined : draft.provider;
116
+ }
69
117
  if (has('user'))
70
- out.user = value.user;
118
+ set('user', draft.user);
71
119
  if (has('password'))
72
- out.password = value.password;
120
+ set('password', draft.password);
73
121
  if (has('inboxFolder'))
74
- out.inboxFolder = value.inboxFolder;
122
+ set('inboxFolder', draft.inboxFolder);
75
123
  if (has('sendApproval'))
76
- out.sendApproval = value.sendApproval;
124
+ set('sendApproval', draft.sendApproval);
77
125
  if (has('maxBodyChars'))
78
- out.maxBodyChars = value.maxBodyChars;
126
+ set('maxBodyChars', draft.maxBodyChars);
79
127
  if (has('downloadDir'))
80
- out.downloadDir = value.downloadDir;
128
+ set('downloadDir', draft.downloadDir);
81
129
  if (has('accountsYaml'))
82
- out.accountsYaml = value.accountsYaml;
83
- if (user === null || user?.imap !== undefined) {
84
- const fields = user === null ? value.imap : (user.imap ?? {});
130
+ set('accountsYaml', draft.accountsYaml);
131
+ // serverPresets is intentionally NOT projected: it is UI-side metadata that
132
+ // resolution never reads, and projecting it would put it into the resolved
133
+ // fingerprint, so saving a preset would dispose every live IMAP connection.
134
+ const imapValue = draft.imap;
135
+ const imapKeys = user === null ? imapValue : user?.imap;
136
+ if (isSet(imapKeys) && isSet(imapValue)) {
85
137
  const imap = {};
86
138
  // Empty host means "use the provider preset" — never project it, or the
87
139
  // preset gets shadowed by '' (issues #3 / #6).
88
- if (fields.host !== undefined && value.imap.host !== '')
89
- imap.host = value.imap.host;
90
- if (fields.port !== undefined)
91
- imap.port = value.imap.port;
92
- if (fields.secure !== undefined)
93
- imap.secure = value.imap.secure;
140
+ if (isSet(imapKeys.host) && isSet(imapValue.host) && imapValue.host !== '')
141
+ imap.host = imapValue.host;
142
+ if (isSet(imapKeys.port) && isSet(imapValue.port))
143
+ imap.port = imapValue.port;
144
+ if (isSet(imapKeys.secure) && isSet(imapValue.secure))
145
+ imap.secure = imapValue.secure;
94
146
  out.imap = imap;
95
147
  }
96
- if (user === null || user?.smtp !== undefined) {
97
- const fields = user === null ? value.smtp : (user.smtp ?? {});
148
+ const smtpValue = draft.smtp;
149
+ const smtpKeys = user === null ? smtpValue : user?.smtp;
150
+ if (isSet(smtpKeys) && isSet(smtpValue)) {
98
151
  const smtp = {};
99
- if (fields.host !== undefined && value.smtp.host !== '')
100
- smtp.host = value.smtp.host;
101
- if (fields.port !== undefined)
102
- smtp.port = value.smtp.port;
103
- if (fields.secure !== undefined)
104
- smtp.secure = value.smtp.secure;
152
+ if (isSet(smtpKeys.host) && isSet(smtpValue.host) && smtpValue.host !== '')
153
+ smtp.host = smtpValue.host;
154
+ if (isSet(smtpKeys.port) && isSet(smtpValue.port))
155
+ smtp.port = smtpValue.port;
156
+ if (isSet(smtpKeys.secure) && isSet(smtpValue.secure))
157
+ smtp.secure = smtpValue.secure;
105
158
  out.smtp = smtp;
106
159
  }
107
160
  return out;
108
161
  }
162
+ /**
163
+ * Render a rejected value for an error message. The old code concatenated the
164
+ * raw value into the sentence, which turned an absent field into the baffling
165
+ * 「未知的邮件服务商undefined」 — a JSON form at least admits that no value was
166
+ * there.
167
+ */
168
+ function describeValue(value) {
169
+ if (typeof value === 'string')
170
+ return '"' + value + '"';
171
+ if (value === undefined)
172
+ return 'undefined';
173
+ try {
174
+ return JSON.stringify(value) ?? String(value);
175
+ }
176
+ catch {
177
+ return String(value);
178
+ }
179
+ }
109
180
  /**
110
181
  * Gentle write-path validation: structural mistakes fail loudly, but an
111
182
  * incomplete account is allowed (tools report the actionable hint at call
112
183
  * time, so an unconfigured install never breaks boot).
184
+ *
185
+ * The value may be partial — the web route validates whatever the page posted,
186
+ * and the card editor's own POST never carries the form fields. 「Missing」 is
187
+ * 「未设置」 for every one of them, exactly as toEmailConfig projects them, so a
188
+ * partial draft is validated only for the fields it actually has.
189
+ *
190
+ * `extraProviders` are the custom preset names in effect: the settings page's
191
+ * provider dropdown offers them beside the 8 built-ins, so a value naming one
192
+ * is a legal choice, not an unknown provider.
113
193
  */
114
- export function validateSettingsValue(value) {
115
- if (value.provider !== '' && !PROVIDER_NAMES.includes(value.provider)) {
116
- throw new Error('未知的邮箱服务商 "' + value.provider + '",可选:' + PROVIDER_NAMES.join('/') + '(或留空手填 IMAP/SMTP 主机)');
194
+ export function validateSettingsValue(value, extraProviders = []) {
195
+ const draft = (value ?? {});
196
+ const provider = draft.provider;
197
+ if (isSet(provider) && provider !== '' && !PROVIDER_NAMES.includes(provider) && !extraProviders.includes(provider)) {
198
+ const names = [...PROVIDER_NAMES, ...extraProviders];
199
+ throw new Error('未知的邮箱服务商 ' + describeValue(provider) + ',可选:' + names.join('/') + '(或留空手填 IMAP/SMTP 主机)');
200
+ }
201
+ const imapPort = endpointPort(draft.imap);
202
+ if (portOutOfRange(imapPort)) {
203
+ throw new Error('IMAP 端口必须在 1-65535 之间,收到 ' + describeValue(imapPort));
204
+ }
205
+ const smtpPort = endpointPort(draft.smtp);
206
+ if (portOutOfRange(smtpPort)) {
207
+ throw new Error('SMTP 端口必须在 1-65535 之间,收到 ' + describeValue(smtpPort));
208
+ }
209
+ if (isSet(draft.maxBodyChars) && (draft.maxBodyChars < 1000 || draft.maxBodyChars > 200000)) {
210
+ throw new Error('正文截断上限必须在 1000-200000 之间,收到 ' + describeValue(draft.maxBodyChars));
117
211
  }
118
- if (value.imap.port < 1 || value.imap.port > 65535)
119
- throw new Error('IMAP 端口必须在 1-65535 之间');
120
- if (value.smtp.port < 1 || value.smtp.port > 65535)
121
- throw new Error('SMTP 端口必须在 1-65535 之间');
122
- if (value.maxBodyChars < 1000 || value.maxBodyChars > 200000)
123
- throw new Error('正文截断上限必须在 1000-200000 之间');
124
212
  }
@@ -279,7 +279,7 @@ export const descriptions = {
279
279
  "email_list": 'List recent emails in a mailbox folder (newest first). Returns uid, date, sender, subject and flags without message bodies; use email_read with a uid to fetch the full text. Optional since/until (dates like 2026-08-01) filter by received date.',
280
280
  "email_read": 'Read one full email message by its uid (from email_list or email_search). Returns the plain-text body (HTML mail is converted; oversized bodies are truncated) plus attachment metadata; use email_attachment to download one.',
281
281
  "email_mark": 'Change an existing message: mark it read/unread, star/unstar it, or move it to another folder. Use after email_list/email_search when the user wants to tidy the mailbox (archive, clear unread, flag important mail). Moving uses the server MOVE/COPY so the uid changes; the new uid is reported when the server provides it.',
282
- "email_search": 'Search emails by a keyword. The server first searches sender, recipients and subject; when that finds nothing and bodySearchFallback is enabled, recent messages are scanned locally including their body. since/until still constrain both paths. Returns the same compact rows as email_list.',
282
+ "email_search": 'Search emails by a keyword. The server first searches sender, recipients and subject; those hits are re-verified against the envelopes and, when none of them really carries the keyword (some servers answer every search with the same uids), recent messages are scanned locally including their body while bodySearchFallback is enabled. since/until still constrain both paths. Returns the same compact rows as email_list.',
283
283
  "email_send": 'Send an email from a configured account, optionally with file attachments (absolute paths, or relative to the dsh process cwd). Sending asks the user for approval (recipient, subject and attachment count are shown) unless sendApproval is disabled; in Full Access mode the approval policy never asks, so the send is refused with an explanation instead. Never invent recipients or content without the user\'s instruction.',
284
284
  "email_reply": 'Reply to, reply-all to, or forward an existing message (mode: reply | reply-all | forward). Recipients come from the original message (your own address is excluded automatically), the subject gets a single Re:/Fwd: prefix, the original text is quoted underneath, and In-Reply-To/References headers keep mail clients threading correctly. mode=forward needs the to parameter. Like email_send, this asks the user for approval before sending. Never invent recipients or content without the user\'s instruction.',
285
285
  "email_folders": 'List the mailbox folders of an account (INBOX, Sent, Trash, custom folders, ...). Use the returned path values as the folder argument of the other email tools.',
package/lib/tools.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { clampInt, PROVIDER_PRESETS } from './config.js';
2
2
  import { messageOf } from './mail-client.js';
3
+ import { NOT_LOGGED_IN_MESSAGE, oauth2StateOf } from './oauth2.js';
3
4
  import { descriptions, parameters, MAX_LIMIT, MARK_ACTIONS, REPLY_MODES, executionSignal, normalizeAttachmentPaths, parseEmailDay, attachmentSchema, foldersSchema, listSchema, markSchema, readSchema, replySchema, sendSchema, watchSchema, renderAttachment, renderFolders, renderHealth, renderList, renderMark, renderRead, renderReply, renderSearch, renderSend, renderWatch, } from './tool-contract.js';
4
5
  export function buildEmailTools(runtime) {
5
6
  const { getPool, getEffectiveSettings, watch: watchCore } = runtime;
@@ -144,11 +145,25 @@ export function buildEmailTools(runtime) {
144
145
  const entries = [...effective.accounts.entries()];
145
146
  for (const [accountName, account] of entries.slice(0, 8)) {
146
147
  const provider = Object.entries(PROVIDER_PRESETS).find(([, preset]) => (preset.imap.host === account.imap.host && preset.smtp.host === account.smtp.host))?.[0] ?? 'custom';
147
- checks.push({
148
- name: '账号 ' + accountName,
149
- ok: true,
150
- detail: provider + ' / ' + account.user + ' / IMAP ' + account.imap.host + ' / SMTP ' + account.smtp.host,
151
- });
148
+ const base = provider + ' / ' + account.user + ' / IMAP ' + account.imap.host + ' / SMTP ' + account.smtp.host;
149
+ // An OAuth2 account with no token is configured, not broken: the
150
+ // missing piece is a browser login, and saying so is the whole
151
+ // point of this check.
152
+ if (account.authKind === 'oauth2') {
153
+ // The address is passed along so a token belonging to a different
154
+ // mailbox is not reported as a working login.
155
+ const state = oauth2StateOf(accountName, account.user);
156
+ const loggedIn = state.state === 'logged-in';
157
+ checks.push({
158
+ name: '账号 ' + accountName,
159
+ ok: true,
160
+ detail: loggedIn
161
+ ? base + ' / OAuth2 已登录' + (state.user !== undefined ? '(' + state.user + ')' : '')
162
+ : base + ' / OAuth2 ' + NOT_LOGGED_IN_MESSAGE,
163
+ });
164
+ continue;
165
+ }
166
+ checks.push({ name: '账号 ' + accountName, ok: true, detail: base });
152
167
  }
153
168
  return { ok: true, plugin: 'dsh-email', accountCount: entries.length, checks };
154
169
  }
package/lib/web.d.ts CHANGED
@@ -1,10 +1,141 @@
1
1
  import { type EmailSettingsValue } from './settings.js';
2
- import { type EmailConfig } from './config.js';
2
+ import { type EmailConfig, type ProviderPreset, type ServerPreset } from './config.js';
3
+ import { type OAuth2State } from './oauth2.js';
3
4
  import type { EmailWatchResult } from './types.js';
4
5
  /** Same-origin route the browser settings section talks to. */
5
6
  export declare const SETTINGS_ROUTE = "/_dsh/dsh-email/settings";
6
7
  /** Same-origin route serving the whale-girl courier image to the widget. */
7
8
  export declare const WHALE_ASSET_ROUTE = "/_dsh/dsh-email/assets/whale";
9
+ /**
10
+ * One account as the settings page renders it: resolved connection parameters
11
+ * plus whether a password exists. Passwords themselves never cross this
12
+ * boundary — the card only needs to know if the field is filled.
13
+ */
14
+ export interface AccountCardData {
15
+ name: string;
16
+ /** undefined = 自定义服务器(无 provider 预设) */
17
+ provider?: string;
18
+ /** 预设自带的显示名;没有 label 时省略,前端回退显示 provider 名 */
19
+ providerLabel?: string;
20
+ user: string;
21
+ hasPassword: boolean;
22
+ /**
23
+ * How this account authenticates. An `oauth2` card shows the device-code
24
+ * login button instead of a 授权码, because Microsoft no longer accepts one.
25
+ */
26
+ authKind: 'oauth2' | 'password';
27
+ /**
28
+ * The `authKind` the account itself pins, when it pins one. `authKind` above
29
+ * is the effective verdict (which pane the editor shows); this is whether the
30
+ * user chose it, which is what the three-way selector has to render back —
31
+ * without it「自动」and「显式密码」look identical and the escape hatch is
32
+ * unreachable from the panel.
33
+ */
34
+ authKindDeclared?: 'oauth2' | 'password';
35
+ /**
36
+ * The application (client) id this account logs in through. Unlike a password
37
+ * this is not a secret — a public-client id travels in every device-code
38
+ * request — so the card carries the value itself and the editor can prefill
39
+ * it. Omitted when the account has none, which for an OAuth2 account is
40
+ * exactly the state that has to be fixed before login can start.
41
+ */
42
+ clientId?: string;
43
+ /** Login state of an OAuth2 account: none / a device code in flight / logged in. */
44
+ oauthState: OAuth2State;
45
+ /** The mailbox address the stored token belongs to (OAuth2 accounts only). */
46
+ oauthUser?: string;
47
+ imap: {
48
+ host: string;
49
+ port: number;
50
+ secure: boolean;
51
+ };
52
+ smtp: {
53
+ host: string;
54
+ port: number;
55
+ secure: boolean;
56
+ };
57
+ inboxFolder: string;
58
+ isDefault: boolean;
59
+ }
60
+ /** One account card as the editor sends it back; every field is optional. */
61
+ export interface AccountCardInput {
62
+ name?: string;
63
+ /** Persisted account key before a UI rename. */
64
+ originalName?: string;
65
+ provider?: string;
66
+ user?: string;
67
+ /**
68
+ * 授权码,三态契约:undefined = 本卡片没提供(保留 YAML 里已存的 password 键),
69
+ * '' = 明确清除(用户在 UI 里清空了密码框),非空 = 写入(数字/布尔先转字符串)。
70
+ * 卡片永远拿不到明文(snapshot 只给 hasPassword),所以「没提供」绝不能当成
71
+ * 「删除」:那会让每一次无关的卡片保存都静默清掉用户已存的授权码。
72
+ */
73
+ password?: string | number | boolean;
74
+ /**
75
+ * OAuth2 应用(客户端)ID,三态契约与 password 相同:undefined = 本卡片没提供
76
+ * (保留 YAML 里已存的 clientId 键),'' = 明确清除,非空 = 写入。插件不内置任何
77
+ * 第三方应用注册,所以这是 OAuth2 账号的必填项,而设置面板是用户唯一的常规入口。
78
+ */
79
+ clientId?: string;
80
+ /**
81
+ * 认证方式覆盖,三态契约同上:undefined = 本卡片没提供(保留已存的 authKind 键),
82
+ * '' = 明确恢复「自动」(删掉该键,回到按 provider/主机派生),非空 = 钉住。
83
+ * 这是给仍能用应用密码连 Exchange Online 的租户(混合/本地部署、SMTP AUTH 未关)
84
+ * 留的退路,没有它,这类账号升级后只会看到「尚未登录」且无处可改。
85
+ */
86
+ authKind?: string;
87
+ inboxFolder?: string;
88
+ imap?: {
89
+ host?: string;
90
+ port?: number;
91
+ secure?: boolean;
92
+ };
93
+ smtp?: {
94
+ host?: string;
95
+ port?: number;
96
+ secure?: boolean;
97
+ };
98
+ }
99
+ /**
100
+ * Verdict on the `Host` header: `undefined` to proceed, otherwise the reason to
101
+ * refuse.
102
+ *
103
+ * The localhost gate on `socket.remoteAddress` proves where the packets came
104
+ * from, not which name the browser believes it is talking to. A page the user
105
+ * visits can point a domain at 127.0.0.1 (DNS rebinding); that request looks
106
+ * same-origin to the browser, carries no `Origin`, and would make the snapshot
107
+ * readable — and the snapshot carries `accountsYaml`, plaintext 授权码 included.
108
+ * Requiring a localhost `Host` closes it.
109
+ *
110
+ * A request with no `Host` at all did not come from a browser (HTTP/1.0, curl,
111
+ * the test harness), and the remote-address gate still applies to it.
112
+ */
113
+ export declare function hostVerdict(host: unknown): string | undefined;
114
+ /**
115
+ * Verdict on a state-changing POST: `undefined` to proceed, otherwise the status
116
+ * and reason to refuse.
117
+ *
118
+ * Cross-origin writes are the hole the remote-address gate cannot see: a browser
119
+ * page may POST here as a「simple request」(text/plain, no preflight) and change
120
+ * settings or trigger a dial. Three independent checks close it:
121
+ *
122
+ * - `application/json` is not a simple-request content type, so a cross-origin
123
+ * caller is forced into a preflight, which this route never answers with
124
+ * `Access-Control-Allow-Origin`.
125
+ * - `Origin`, when it names an http(s) page, must be a localhost origin. Other
126
+ * schemes are left to the next check: the host may load its UI through a
127
+ * custom protocol, and a hostile page cannot produce one.
128
+ * - `Sec-Fetch-Site`, when present, must be `same-origin` (or `none`, a
129
+ * user-initiated navigation with no referrer). This is what catches an opaque
130
+ * `Origin: null` from a sandboxed iframe.
131
+ *
132
+ * Headers a non-browser client omits are not fabricatable by page script, so
133
+ * their absence is allowed rather than treated as a rejection.
134
+ */
135
+ export declare function postVerdict(headers: Record<string, unknown>): {
136
+ status: number;
137
+ message: string;
138
+ } | undefined;
8
139
  /**
9
140
  * Browser-facing backend: snapshot the settings namespace, save it with
10
141
  * optimistic concurrency, and test a draft account over a live IMAP login.
@@ -27,6 +158,16 @@ export declare class EmailSettingsBackend {
27
158
  };
28
159
  writable: boolean;
29
160
  accounts: string[];
161
+ accountsDetail: {
162
+ error?: string | undefined;
163
+ list: AccountCardData[];
164
+ defaultAccount?: string | undefined;
165
+ };
166
+ presets: {
167
+ custom: Record<string, ServerPreset>;
168
+ error?: string;
169
+ builtin: Record<string, ProviderPreset>;
170
+ };
30
171
  whale: {
31
172
  url: string;
32
173
  skin: boolean;
@@ -42,16 +183,59 @@ export declare class EmailSettingsBackend {
42
183
  };
43
184
  writable: boolean;
44
185
  accounts: string[];
186
+ accountsDetail: {
187
+ error?: string | undefined;
188
+ list: AccountCardData[];
189
+ defaultAccount?: string | undefined;
190
+ };
191
+ presets: {
192
+ custom: Record<string, ServerPreset>;
193
+ error?: string;
194
+ builtin: Record<string, ProviderPreset>;
195
+ };
45
196
  whale: {
46
197
  url: string;
47
198
  skin: boolean;
48
199
  credit: string;
49
200
  };
50
201
  }>;
51
- test(value: EmailSettingsValue): Promise<{
202
+ /**
203
+ * Test one account (by name, defaulting to the draft's default account) over
204
+ * a live IMAP login. Returns the endpoint it dialled so the panel can show
205
+ * what was actually tried — including on failure.
206
+ */
207
+ test(value: EmailSettingsValue, accountName?: string): Promise<{
208
+ account: string;
209
+ imapHost: string;
210
+ imapPort: number;
52
211
  ok: boolean;
53
212
  ms: number;
54
213
  }>;
214
+ /**
215
+ * Resolve one named account of the *stored* settings — the same accounts the
216
+ * tools and the card list see. A login is not a draft operation: the settings
217
+ * page saves the card before it starts one, so the account being logged into
218
+ * is by definition already persisted.
219
+ */
220
+ private oauthAccount;
221
+ /**
222
+ * Start (or report) the device-code login for one OAuth2 account.
223
+ *
224
+ * An account that already holds a token answers `already` — the card shows
225
+ * 「已登录」and there is no second code to hand out. Otherwise the authority's
226
+ * device code is returned verbatim: url = verification_uri, code = user_code,
227
+ * and both interval and expires_in in seconds, which is the unit the page
228
+ * schedules its polling with.
229
+ */
230
+ oauthLogin(name: unknown): Promise<Record<string, unknown>>;
231
+ /**
232
+ * One poll of an in-flight device-code login.
233
+ *
234
+ * `authorization_pending` is the ordinary answer for as long as the user has
235
+ * not finished in the browser, so it is reported as a state rather than an
236
+ * error: only a refused or expired flow comes back as ok:false.
237
+ */
238
+ oauthPoll(name: unknown): Promise<Record<string, unknown>>;
55
239
  responseJson(res: any, status: number, body: unknown): void;
56
240
  handle(req: any, res: any): Promise<void>;
57
241
  /** GET-only localhost route serving the whale-girl courier image. */