dsh-email 0.10.7 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/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
  }
@@ -350,6 +350,9 @@ export declare const watchSchema: {
350
350
  firstRun: {
351
351
  type: string;
352
352
  };
353
+ reset: {
354
+ type: string;
355
+ };
353
356
  newCount: {
354
357
  type: string;
355
358
  };
@@ -199,7 +199,11 @@ export function renderSearch(value) {
199
199
  return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":共 ' + value.count + ' 条匹配,本次没有列出。');
200
200
  }
201
201
  const lines = value.messages.map((m, i) => '#' + (i + 1) + ' ' + describeMessage(m));
202
- return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":共 ' + value.count + ' 条匹配,展示最新 ' + value.messages.length + ' 条:\n\n' + lines.join('\n'));
202
+ const offset = value.offset ?? 0;
203
+ const window = offset > 0
204
+ ? '跳过最新 ' + offset + ' 条后展示 ' + value.messages.length + ' 条'
205
+ : '展示最新 ' + value.messages.length + ' 条';
206
+ return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":共 ' + value.count + ' 条匹配,' + window + ':\n\n' + lines.join('\n'));
203
207
  }
204
208
  export function renderSend(value) {
205
209
  const rejected = value.rejected.length > 0 ? ';被拒:' + value.rejected.join(', ') : '';
@@ -229,6 +233,9 @@ export function renderReply(value) {
229
233
  return oneText('账号 ' + value.account + ' 已' + REPLY_LABELS[value.mode] + ' uid=' + value.originalUid + ' 的邮件:收件人 ' + value.to.join(', ') + ',主题「' + value.subject + '」,messageId: ' + value.messageId + rejected);
230
234
  }
231
235
  export function renderWatch(value) {
236
+ if (value.reset === true) {
237
+ return oneText('账号 ' + value.account + ':文件夹 "' + value.folder + '" 的 UIDVALIDITY 已变化(服务器重新编号了邮件),已重新建立基线(当前未读 ' + value.totalUnread + ' 封)。这次不报告新邮件,之后照常。');
238
+ }
232
239
  if (value.firstRun) {
233
240
  return oneText('账号 ' + value.account + ':已建立新邮件监视基线(当前未读 ' + value.totalUnread + ' 封)。之后调用 email_watch 只会报告新到的邮件。');
234
241
  }
@@ -269,6 +276,7 @@ export const watchSchema = {
269
276
  account: { type: 'string' },
270
277
  folder: { type: 'string' },
271
278
  firstRun: { type: 'boolean' },
279
+ reset: { type: 'boolean' },
272
280
  newCount: { type: 'integer' },
273
281
  totalUnread: { type: 'integer' },
274
282
  messages: { type: 'array', items: { type: 'object', properties: messageShape, additionalProperties: true } },
@@ -279,7 +287,7 @@ export const descriptions = {
279
287
  "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
288
  "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
289
  "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.',
290
+ "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. offset skips the newest matches for paging; since/until still constrain both paths. Returns the same compact rows as email_list.',
283
291
  "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
292
  "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
293
  "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.',
@@ -313,6 +321,7 @@ export const parameters = {
313
321
  query: { type: 'string', required: true, description: 'Keyword to search for' },
314
322
  folder: { type: 'string', description: 'IMAP folder to search in; defaults to the account inboxFolder' },
315
323
  limit: { type: 'integer', description: 'How many matches to return, 1-100, default 10' },
324
+ offset: { type: 'integer', description: 'Skip this many newest matches first, default 0' },
316
325
  since: { type: 'string', description: 'Only search messages received on or after this date, e.g. 2026-08-01 (optional)' },
317
326
  until: { type: 'string', description: 'Only search messages received on or before this date, e.g. 2026-08-26 (optional)' },
318
327
  account: { type: 'string', description: ACCOUNT_HINT },
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;
@@ -73,9 +74,10 @@ export function buildEmailTools(runtime) {
73
74
  if (typeof args.query !== 'string' || args.query.trim() === '')
74
75
  throw new Error('query 不能为空');
75
76
  const limit = clampInt(args.limit, 10, 1, MAX_LIMIT);
77
+ const offset = clampInt(args.offset, 0, 0, 10000);
76
78
  const since = args.since?.trim() ? parseEmailDay(args.since, 'since') : undefined;
77
79
  const until = args.until?.trim() ? parseEmailDay(args.until, 'until', true) : undefined;
78
- return await getPool().search(args.account, args.query.trim(), args.folder?.trim() || '', limit, since, until, executionSignal(exec));
80
+ return await getPool().search(args.account, args.query.trim(), args.folder?.trim() || '', limit, offset, since, until, executionSignal(exec));
79
81
  }
80
82
  },
81
83
  {
@@ -144,11 +146,25 @@ export function buildEmailTools(runtime) {
144
146
  const entries = [...effective.accounts.entries()];
145
147
  for (const [accountName, account] of entries.slice(0, 8)) {
146
148
  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
- });
149
+ const base = provider + ' / ' + account.user + ' / IMAP ' + account.imap.host + ' / SMTP ' + account.smtp.host;
150
+ // An OAuth2 account with no token is configured, not broken: the
151
+ // missing piece is a browser login, and saying so is the whole
152
+ // point of this check.
153
+ if (account.authKind === 'oauth2') {
154
+ // The address is passed along so a token belonging to a different
155
+ // mailbox is not reported as a working login.
156
+ const state = oauth2StateOf(accountName, account.user);
157
+ const loggedIn = state.state === 'logged-in';
158
+ checks.push({
159
+ name: '账号 ' + accountName,
160
+ ok: true,
161
+ detail: loggedIn
162
+ ? base + ' / OAuth2 已登录' + (state.user !== undefined ? '(' + state.user + ')' : '')
163
+ : base + ' / OAuth2 ' + NOT_LOGGED_IN_MESSAGE,
164
+ });
165
+ continue;
166
+ }
167
+ checks.push({ name: '账号 ' + accountName, ok: true, detail: base });
152
168
  }
153
169
  return { ok: true, plugin: 'dsh-email', accountCount: entries.length, checks };
154
170
  }
package/lib/types.d.ts CHANGED
@@ -39,6 +39,8 @@ export interface EmailListResult {
39
39
  account: string;
40
40
  count: number;
41
41
  folder: string;
42
+ /** The mailbox's UIDVALIDITY; 0 when the server did not report one. */
43
+ uidValidity: number;
42
44
  messages: ListedMessage[];
43
45
  }
44
46
  export interface EmailReadResult extends ReadMessageBody {
@@ -51,6 +53,8 @@ export interface EmailSearchResult {
51
53
  query: string;
52
54
  count: number;
53
55
  folder: string;
56
+ /** How many newest matches the caller skipped (0 on the first page). */
57
+ offset: number;
54
58
  messages: ListedMessage[];
55
59
  }
56
60
  export interface EmailSendResult {
@@ -99,6 +103,7 @@ export interface EmailSearchArgs extends AccountArg {
99
103
  query: string;
100
104
  folder?: string;
101
105
  limit?: number;
106
+ offset?: number;
102
107
  since?: string;
103
108
  until?: string;
104
109
  }
@@ -175,6 +180,8 @@ export interface EmailWatchResult {
175
180
  /** Unread messages never reported before (empty on firstRun). */
176
181
  newCount: number;
177
182
  messages: ListedMessage[];
183
+ /** True when a server-side UIDVALIDITY change forced a fresh baseline. */
184
+ reset?: boolean;
178
185
  /** Total unread in the folder right now. */
179
186
  totalUnread: number;
180
187
  }
package/lib/web.d.ts CHANGED
@@ -1,10 +1,164 @@
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
+ /** Display name for the From header, when the account sets one. */
44
+ senderName?: string;
45
+ /** Login user when it differs from the visible address (`user`). */
46
+ authUser?: string;
47
+ /** Whether a login password separate from `password` is stored. */
48
+ hasAuthPassword?: boolean;
49
+ /** Login state of an OAuth2 account: none / a device code in flight / logged in. */
50
+ oauthState: OAuth2State;
51
+ /** The mailbox address the stored token belongs to (OAuth2 accounts only). */
52
+ oauthUser?: string;
53
+ imap: {
54
+ host: string;
55
+ port: number;
56
+ secure: boolean;
57
+ };
58
+ smtp: {
59
+ host: string;
60
+ port: number;
61
+ secure: boolean;
62
+ };
63
+ inboxFolder: string;
64
+ isDefault: boolean;
65
+ }
66
+ /** One account card as the editor sends it back; every field is optional. */
67
+ export interface AccountCardInput {
68
+ name?: string;
69
+ /** Persisted account key before a UI rename. */
70
+ originalName?: string;
71
+ provider?: string;
72
+ user?: string;
73
+ /**
74
+ * 授权码,三态契约:undefined = 本卡片没提供(保留 YAML 里已存的 password 键),
75
+ * '' = 明确清除(用户在 UI 里清空了密码框),非空 = 写入(数字/布尔先转字符串)。
76
+ * 卡片永远拿不到明文(snapshot 只给 hasPassword),所以「没提供」绝不能当成
77
+ * 「删除」:那会让每一次无关的卡片保存都静默清掉用户已存的授权码。
78
+ */
79
+ password?: string | number | boolean;
80
+ /**
81
+ * OAuth2 应用(客户端)ID,三态契约与 password 相同:undefined = 本卡片没提供
82
+ * (保留 YAML 里已存的 clientId 键),'' = 明确清除,非空 = 写入。插件不内置任何
83
+ * 第三方应用注册,所以这是 OAuth2 账号的必填项,而设置面板是用户唯一的常规入口。
84
+ */
85
+ clientId?: string;
86
+ /**
87
+ * 发件显示名,三态契约同 clientId:undefined = 本卡片没提供(保留已存的
88
+ * senderName 键),'' = 明确清除,非空 = 写入。只改收件人看到的名称,发件地址
89
+ * 始终是 user。
90
+ */
91
+ senderName?: string;
92
+ /**
93
+ * 登录账号(IMAP/SMTP 认证用),三态契约同上:undefined = 保留,'' = 清除(回到
94
+ * 用 user 登录),非空 = 写入。别名/中继场景下 user 是发件地址,它才是登录名。
95
+ */
96
+ authUser?: string;
97
+ /**
98
+ * 登录账号自己的密码,三态契约与 password 完全相同(undefined = 保留已存的值,
99
+ * '' = 明确清除,非空 = 写入)。只有 authUser 与 user 不同、且密码也不一样时
100
+ * 才需要。
101
+ */
102
+ authPassword?: string;
103
+ /**
104
+ * 认证方式覆盖,三态契约同上:undefined = 本卡片没提供(保留已存的 authKind 键),
105
+ * '' = 明确恢复「自动」(删掉该键,回到按 provider/主机派生),非空 = 钉住。
106
+ * 这是给仍能用应用密码连 Exchange Online 的租户(混合/本地部署、SMTP AUTH 未关)
107
+ * 留的退路,没有它,这类账号升级后只会看到「尚未登录」且无处可改。
108
+ */
109
+ authKind?: string;
110
+ inboxFolder?: string;
111
+ imap?: {
112
+ host?: string;
113
+ port?: number;
114
+ secure?: boolean;
115
+ };
116
+ smtp?: {
117
+ host?: string;
118
+ port?: number;
119
+ secure?: boolean;
120
+ };
121
+ }
122
+ /**
123
+ * Verdict on the `Host` header: `undefined` to proceed, otherwise the reason to
124
+ * refuse.
125
+ *
126
+ * The localhost gate on `socket.remoteAddress` proves where the packets came
127
+ * from, not which name the browser believes it is talking to. A page the user
128
+ * visits can point a domain at 127.0.0.1 (DNS rebinding); that request looks
129
+ * same-origin to the browser, carries no `Origin`, and would make the snapshot
130
+ * readable — and the snapshot carries `accountsYaml`, plaintext 授权码 included.
131
+ * Requiring a localhost `Host` closes it.
132
+ *
133
+ * A request with no `Host` at all did not come from a browser (HTTP/1.0, curl,
134
+ * the test harness), and the remote-address gate still applies to it.
135
+ */
136
+ export declare function hostVerdict(host: unknown): string | undefined;
137
+ /**
138
+ * Verdict on a state-changing POST: `undefined` to proceed, otherwise the status
139
+ * and reason to refuse.
140
+ *
141
+ * Cross-origin writes are the hole the remote-address gate cannot see: a browser
142
+ * page may POST here as a「simple request」(text/plain, no preflight) and change
143
+ * settings or trigger a dial. Three independent checks close it:
144
+ *
145
+ * - `application/json` is not a simple-request content type, so a cross-origin
146
+ * caller is forced into a preflight, which this route never answers with
147
+ * `Access-Control-Allow-Origin`.
148
+ * - `Origin`, when it names an http(s) page, must be a localhost origin. Other
149
+ * schemes are left to the next check: the host may load its UI through a
150
+ * custom protocol, and a hostile page cannot produce one.
151
+ * - `Sec-Fetch-Site`, when present, must be `same-origin` (or `none`, a
152
+ * user-initiated navigation with no referrer). This is what catches an opaque
153
+ * `Origin: null` from a sandboxed iframe.
154
+ *
155
+ * Headers a non-browser client omits are not fabricatable by page script, so
156
+ * their absence is allowed rather than treated as a rejection.
157
+ */
158
+ export declare function postVerdict(headers: Record<string, unknown>): {
159
+ status: number;
160
+ message: string;
161
+ } | undefined;
8
162
  /**
9
163
  * Browser-facing backend: snapshot the settings namespace, save it with
10
164
  * optimistic concurrency, and test a draft account over a live IMAP login.
@@ -27,6 +181,16 @@ export declare class EmailSettingsBackend {
27
181
  };
28
182
  writable: boolean;
29
183
  accounts: string[];
184
+ accountsDetail: {
185
+ error?: string | undefined;
186
+ list: AccountCardData[];
187
+ defaultAccount?: string | undefined;
188
+ };
189
+ presets: {
190
+ custom: Record<string, ServerPreset>;
191
+ error?: string;
192
+ builtin: Record<string, ProviderPreset>;
193
+ };
30
194
  whale: {
31
195
  url: string;
32
196
  skin: boolean;
@@ -42,16 +206,59 @@ export declare class EmailSettingsBackend {
42
206
  };
43
207
  writable: boolean;
44
208
  accounts: string[];
209
+ accountsDetail: {
210
+ error?: string | undefined;
211
+ list: AccountCardData[];
212
+ defaultAccount?: string | undefined;
213
+ };
214
+ presets: {
215
+ custom: Record<string, ServerPreset>;
216
+ error?: string;
217
+ builtin: Record<string, ProviderPreset>;
218
+ };
45
219
  whale: {
46
220
  url: string;
47
221
  skin: boolean;
48
222
  credit: string;
49
223
  };
50
224
  }>;
51
- test(value: EmailSettingsValue): Promise<{
225
+ /**
226
+ * Test one account (by name, defaulting to the draft's default account) over
227
+ * a live IMAP login. Returns the endpoint it dialled so the panel can show
228
+ * what was actually tried — including on failure.
229
+ */
230
+ test(value: EmailSettingsValue, accountName?: string): Promise<{
231
+ account: string;
232
+ imapHost: string;
233
+ imapPort: number;
52
234
  ok: boolean;
53
235
  ms: number;
54
236
  }>;
237
+ /**
238
+ * Resolve one named account of the *stored* settings — the same accounts the
239
+ * tools and the card list see. A login is not a draft operation: the settings
240
+ * page saves the card before it starts one, so the account being logged into
241
+ * is by definition already persisted.
242
+ */
243
+ private oauthAccount;
244
+ /**
245
+ * Start (or report) the device-code login for one OAuth2 account.
246
+ *
247
+ * An account that already holds a token answers `already` — the card shows
248
+ * 「已登录」and there is no second code to hand out. Otherwise the authority's
249
+ * device code is returned verbatim: url = verification_uri, code = user_code,
250
+ * and both interval and expires_in in seconds, which is the unit the page
251
+ * schedules its polling with.
252
+ */
253
+ oauthLogin(name: unknown): Promise<Record<string, unknown>>;
254
+ /**
255
+ * One poll of an in-flight device-code login.
256
+ *
257
+ * `authorization_pending` is the ordinary answer for as long as the user has
258
+ * not finished in the browser, so it is reported as a state rather than an
259
+ * error: only a refused or expired flow comes back as ok:false.
260
+ */
261
+ oauthPoll(name: unknown): Promise<Record<string, unknown>>;
55
262
  responseJson(res: any, status: number, body: unknown): void;
56
263
  handle(req: any, res: any): Promise<void>;
57
264
  /** GET-only localhost route serving the whale-girl courier image. */