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/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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
118
|
+
set('user', draft.user);
|
|
71
119
|
if (has('password'))
|
|
72
|
-
|
|
120
|
+
set('password', draft.password);
|
|
73
121
|
if (has('inboxFolder'))
|
|
74
|
-
|
|
122
|
+
set('inboxFolder', draft.inboxFolder);
|
|
75
123
|
if (has('sendApproval'))
|
|
76
|
-
|
|
124
|
+
set('sendApproval', draft.sendApproval);
|
|
77
125
|
if (has('maxBodyChars'))
|
|
78
|
-
|
|
126
|
+
set('maxBodyChars', draft.maxBodyChars);
|
|
79
127
|
if (has('downloadDir'))
|
|
80
|
-
|
|
128
|
+
set('downloadDir', draft.downloadDir);
|
|
81
129
|
if (has('accountsYaml'))
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 (
|
|
89
|
-
imap.host =
|
|
90
|
-
if (
|
|
91
|
-
imap.port =
|
|
92
|
-
if (
|
|
93
|
-
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
|
-
|
|
97
|
-
|
|
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 (
|
|
100
|
-
smtp.host =
|
|
101
|
-
if (
|
|
102
|
-
smtp.port =
|
|
103
|
-
if (
|
|
104
|
-
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
|
-
|
|
116
|
-
|
|
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
|
}
|
package/lib/tool-contract.d.ts
CHANGED
package/lib/tool-contract.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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. */
|