dsh-email 0.11.0 → 0.13.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/CHANGELOG.md +36 -0
- package/README.en.md +8 -12
- package/README.md +8 -15
- package/lib/client.js +109 -22
- package/lib/config.d.ts +20 -0
- package/lib/config.js +16 -4
- package/lib/mail-client.d.ts +41 -5
- package/lib/mail-client.js +284 -52
- package/lib/parse.js +4 -2
- package/lib/runtime.d.ts +1 -1
- package/lib/runtime.js +33 -15
- package/lib/tool-contract.d.ts +3 -0
- package/lib/tool-contract.js +20 -2
- package/lib/tools.d.ts +1 -0
- package/lib/tools.js +15 -1
- package/lib/types.d.ts +12 -0
- package/lib/web.d.ts +23 -0
- package/lib/web.js +62 -6
- package/package.json +4 -3
package/lib/tool-contract.js
CHANGED
|
@@ -195,11 +195,24 @@ export function renderRead(value) {
|
|
|
195
195
|
return oneText('账号 ' + value.account + ',主题:' + (value.subject || '(无主题)') + '\n来自:' + from + '\n时间:' + (value.date || '(未知)') + attach + '\n\n' + value.text);
|
|
196
196
|
}
|
|
197
197
|
export function renderSearch(value) {
|
|
198
|
+
const scanned = value.countKind === 'scanned';
|
|
199
|
+
// 回退扫描只看了最近 scannedLimit 封,不知道全文件夹匹配数:不能把本页条数说成「共 N 条」。
|
|
200
|
+
const scannedNote = '仅扫描最近 ' + (value.scannedLimit ?? 0) + ' 封,未统计全文件夹匹配数';
|
|
198
201
|
if (value.messages.length === 0) {
|
|
202
|
+
if (scanned) {
|
|
203
|
+
return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":本页没有匹配(' + scannedNote + ')。');
|
|
204
|
+
}
|
|
199
205
|
return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":共 ' + value.count + ' 条匹配,本次没有列出。');
|
|
200
206
|
}
|
|
201
207
|
const lines = value.messages.map((m, i) => '#' + (i + 1) + ' ' + describeMessage(m));
|
|
202
|
-
|
|
208
|
+
const offset = value.offset ?? 0;
|
|
209
|
+
const window = offset > 0
|
|
210
|
+
? '跳过最新 ' + offset + ' 条后展示 ' + value.messages.length + ' 条'
|
|
211
|
+
: '展示最新 ' + value.messages.length + ' 条';
|
|
212
|
+
const summary = scanned
|
|
213
|
+
? '本页 ' + value.messages.length + ' 条(' + scannedNote + ')' + (offset > 0 ? ',已被 offset 跳过最新 ' + offset + ' 条' : '')
|
|
214
|
+
: '共 ' + value.count + ' 条匹配,' + window;
|
|
215
|
+
return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":' + summary + ':\n\n' + lines.join('\n'));
|
|
203
216
|
}
|
|
204
217
|
export function renderSend(value) {
|
|
205
218
|
const rejected = value.rejected.length > 0 ? ';被拒:' + value.rejected.join(', ') : '';
|
|
@@ -229,6 +242,9 @@ export function renderReply(value) {
|
|
|
229
242
|
return oneText('账号 ' + value.account + ' 已' + REPLY_LABELS[value.mode] + ' uid=' + value.originalUid + ' 的邮件:收件人 ' + value.to.join(', ') + ',主题「' + value.subject + '」,messageId: ' + value.messageId + rejected);
|
|
230
243
|
}
|
|
231
244
|
export function renderWatch(value) {
|
|
245
|
+
if (value.reset === true) {
|
|
246
|
+
return oneText('账号 ' + value.account + ':文件夹 "' + value.folder + '" 的 UIDVALIDITY 已变化(服务器重新编号了邮件),已重新建立基线(当前未读 ' + value.totalUnread + ' 封)。这次不报告新邮件,之后照常。');
|
|
247
|
+
}
|
|
232
248
|
if (value.firstRun) {
|
|
233
249
|
return oneText('账号 ' + value.account + ':已建立新邮件监视基线(当前未读 ' + value.totalUnread + ' 封)。之后调用 email_watch 只会报告新到的邮件。');
|
|
234
250
|
}
|
|
@@ -269,6 +285,7 @@ export const watchSchema = {
|
|
|
269
285
|
account: { type: 'string' },
|
|
270
286
|
folder: { type: 'string' },
|
|
271
287
|
firstRun: { type: 'boolean' },
|
|
288
|
+
reset: { type: 'boolean' },
|
|
272
289
|
newCount: { type: 'integer' },
|
|
273
290
|
totalUnread: { type: 'integer' },
|
|
274
291
|
messages: { type: 'array', items: { type: 'object', properties: messageShape, additionalProperties: true } },
|
|
@@ -279,7 +296,7 @@ export const descriptions = {
|
|
|
279
296
|
"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
297
|
"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
298
|
"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; 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.',
|
|
299
|
+
"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
300
|
"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
301
|
"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
302
|
"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 +330,7 @@ export const parameters = {
|
|
|
313
330
|
query: { type: 'string', required: true, description: 'Keyword to search for' },
|
|
314
331
|
folder: { type: 'string', description: 'IMAP folder to search in; defaults to the account inboxFolder' },
|
|
315
332
|
limit: { type: 'integer', description: 'How many matches to return, 1-100, default 10' },
|
|
333
|
+
offset: { type: 'integer', description: 'Skip this many newest matches first, default 0' },
|
|
316
334
|
since: { type: 'string', description: 'Only search messages received on or after this date, e.g. 2026-08-01 (optional)' },
|
|
317
335
|
until: { type: 'string', description: 'Only search messages received on or before this date, e.g. 2026-08-26 (optional)' },
|
|
318
336
|
account: { type: 'string', description: ACCOUNT_HINT },
|
package/lib/tools.d.ts
CHANGED
|
@@ -9,5 +9,6 @@ export interface EmailToolDefinition {
|
|
|
9
9
|
render(args: unknown, value: unknown): TextBlock[];
|
|
10
10
|
};
|
|
11
11
|
execute(args: unknown, exec?: unknown): Promise<unknown>;
|
|
12
|
+
timeoutMs?: number;
|
|
12
13
|
}
|
|
13
14
|
export declare function buildEmailTools(runtime: Pick<EmailRuntime, 'getPool' | 'getEffectiveSettings' | 'watch'>): EmailToolDefinition[];
|
package/lib/tools.js
CHANGED
|
@@ -2,6 +2,9 @@ import { clampInt, PROVIDER_PRESETS } from './config.js';
|
|
|
2
2
|
import { messageOf } from './mail-client.js';
|
|
3
3
|
import { NOT_LOGGED_IN_MESSAGE, oauth2StateOf } from './oauth2.js';
|
|
4
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';
|
|
5
|
+
/** 单次工具调用的 deadline:普通查询 60s,正文扫描/watch 这类整批操作 120s。 */
|
|
6
|
+
const QUERY_TIMEOUT_MS = 60000;
|
|
7
|
+
const BATCH_TIMEOUT_MS = 120000;
|
|
5
8
|
export function buildEmailTools(runtime) {
|
|
6
9
|
const { getPool, getEffectiveSettings, watch: watchCore } = runtime;
|
|
7
10
|
return [
|
|
@@ -13,6 +16,7 @@ export function buildEmailTools(runtime) {
|
|
|
13
16
|
schema: listSchema,
|
|
14
17
|
render: (_args, value) => renderList(value),
|
|
15
18
|
},
|
|
19
|
+
timeoutMs: QUERY_TIMEOUT_MS,
|
|
16
20
|
async execute(rawArgs, exec) {
|
|
17
21
|
const args = rawArgs;
|
|
18
22
|
const limit = clampInt(args.limit, 20, 1, MAX_LIMIT);
|
|
@@ -30,6 +34,7 @@ export function buildEmailTools(runtime) {
|
|
|
30
34
|
schema: readSchema,
|
|
31
35
|
render: (_args, value) => renderRead(value),
|
|
32
36
|
},
|
|
37
|
+
timeoutMs: QUERY_TIMEOUT_MS,
|
|
33
38
|
async execute(rawArgs, exec) {
|
|
34
39
|
const args = rawArgs;
|
|
35
40
|
if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
|
|
@@ -46,6 +51,7 @@ export function buildEmailTools(runtime) {
|
|
|
46
51
|
schema: markSchema,
|
|
47
52
|
render: (_args, value) => renderMark(value),
|
|
48
53
|
},
|
|
54
|
+
timeoutMs: QUERY_TIMEOUT_MS,
|
|
49
55
|
async execute(rawArgs, exec) {
|
|
50
56
|
const args = rawArgs;
|
|
51
57
|
if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
|
|
@@ -69,14 +75,16 @@ export function buildEmailTools(runtime) {
|
|
|
69
75
|
schema: listSchema,
|
|
70
76
|
render: (_args, value) => renderSearch(value),
|
|
71
77
|
},
|
|
78
|
+
timeoutMs: BATCH_TIMEOUT_MS,
|
|
72
79
|
async execute(rawArgs, exec) {
|
|
73
80
|
const args = rawArgs;
|
|
74
81
|
if (typeof args.query !== 'string' || args.query.trim() === '')
|
|
75
82
|
throw new Error('query 不能为空');
|
|
76
83
|
const limit = clampInt(args.limit, 10, 1, MAX_LIMIT);
|
|
84
|
+
const offset = clampInt(args.offset, 0, 0, 10000);
|
|
77
85
|
const since = args.since?.trim() ? parseEmailDay(args.since, 'since') : undefined;
|
|
78
86
|
const until = args.until?.trim() ? parseEmailDay(args.until, 'until', true) : undefined;
|
|
79
|
-
return await getPool().search(args.account, args.query.trim(), args.folder?.trim() || '', limit, since, until, executionSignal(exec));
|
|
87
|
+
return await getPool().search(args.account, args.query.trim(), args.folder?.trim() || '', limit, offset, since, until, executionSignal(exec));
|
|
80
88
|
}
|
|
81
89
|
},
|
|
82
90
|
{
|
|
@@ -87,6 +95,7 @@ export function buildEmailTools(runtime) {
|
|
|
87
95
|
schema: sendSchema,
|
|
88
96
|
render: (_args, value) => renderSend(value),
|
|
89
97
|
},
|
|
98
|
+
timeoutMs: QUERY_TIMEOUT_MS,
|
|
90
99
|
async execute(rawArgs, exec) {
|
|
91
100
|
const args = rawArgs;
|
|
92
101
|
if (typeof args.to !== 'string' || args.to.trim() === '')
|
|
@@ -101,6 +110,7 @@ export function buildEmailTools(runtime) {
|
|
|
101
110
|
description: descriptions.email_reply,
|
|
102
111
|
parameters: parameters.email_reply,
|
|
103
112
|
output: { schema: replySchema, render: (_args, value) => renderReply(value) },
|
|
113
|
+
timeoutMs: QUERY_TIMEOUT_MS,
|
|
104
114
|
async execute(rawArgs, exec) {
|
|
105
115
|
const args = rawArgs;
|
|
106
116
|
if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
|
|
@@ -127,6 +137,7 @@ export function buildEmailTools(runtime) {
|
|
|
127
137
|
schema: foldersSchema,
|
|
128
138
|
render: (_args, value) => renderFolders(value),
|
|
129
139
|
},
|
|
140
|
+
timeoutMs: QUERY_TIMEOUT_MS,
|
|
130
141
|
async execute(rawArgs, exec) {
|
|
131
142
|
const args = rawArgs;
|
|
132
143
|
return await getPool().folders(args.account, args.subscribedOnly === true, executionSignal(exec));
|
|
@@ -137,6 +148,7 @@ export function buildEmailTools(runtime) {
|
|
|
137
148
|
description: descriptions.email_health,
|
|
138
149
|
parameters: parameters.email_health,
|
|
139
150
|
output: { schema: { type: 'object', additionalProperties: true }, render: renderHealth },
|
|
151
|
+
timeoutMs: QUERY_TIMEOUT_MS,
|
|
140
152
|
async execute(_rawArgs, exec) {
|
|
141
153
|
executionSignal(exec)?.throwIfAborted();
|
|
142
154
|
const checks = [];
|
|
@@ -181,6 +193,7 @@ export function buildEmailTools(runtime) {
|
|
|
181
193
|
schema: attachmentSchema,
|
|
182
194
|
render: (_args, value) => renderAttachment(value),
|
|
183
195
|
},
|
|
196
|
+
timeoutMs: QUERY_TIMEOUT_MS,
|
|
184
197
|
async execute(rawArgs, exec) {
|
|
185
198
|
const args = rawArgs;
|
|
186
199
|
if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
|
|
@@ -196,6 +209,7 @@ export function buildEmailTools(runtime) {
|
|
|
196
209
|
description: descriptions.email_watch,
|
|
197
210
|
parameters: parameters.email_watch,
|
|
198
211
|
output: { schema: watchSchema, render: (_args, value) => renderWatch(value) },
|
|
212
|
+
timeoutMs: BATCH_TIMEOUT_MS,
|
|
199
213
|
async execute(rawArgs, exec) {
|
|
200
214
|
const args = rawArgs;
|
|
201
215
|
const limit = clampInt(args.limit, 20, 1, MAX_LIMIT);
|
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 {
|
|
@@ -49,9 +51,16 @@ export interface EmailReadResult extends ReadMessageBody {
|
|
|
49
51
|
export interface EmailSearchResult {
|
|
50
52
|
account: string;
|
|
51
53
|
query: string;
|
|
54
|
+
/** Server path: the server's match count. Fallback scan: this page's row count (see countKind). */
|
|
52
55
|
count: number;
|
|
53
56
|
folder: string;
|
|
57
|
+
/** How many newest matches the caller skipped (0 on the first page). */
|
|
58
|
+
offset: number;
|
|
54
59
|
messages: ListedMessage[];
|
|
60
|
+
/** Set to 'scanned' when the local body scan produced this page, so count is not a total. */
|
|
61
|
+
countKind?: 'scanned';
|
|
62
|
+
/** How many newest messages the fallback scan looked at (set with countKind='scanned'). */
|
|
63
|
+
scannedLimit?: number;
|
|
55
64
|
}
|
|
56
65
|
export interface EmailSendResult {
|
|
57
66
|
account: string;
|
|
@@ -99,6 +108,7 @@ export interface EmailSearchArgs extends AccountArg {
|
|
|
99
108
|
query: string;
|
|
100
109
|
folder?: string;
|
|
101
110
|
limit?: number;
|
|
111
|
+
offset?: number;
|
|
102
112
|
since?: string;
|
|
103
113
|
until?: string;
|
|
104
114
|
}
|
|
@@ -175,6 +185,8 @@ export interface EmailWatchResult {
|
|
|
175
185
|
/** Unread messages never reported before (empty on firstRun). */
|
|
176
186
|
newCount: number;
|
|
177
187
|
messages: ListedMessage[];
|
|
188
|
+
/** True when a server-side UIDVALIDITY change forced a fresh baseline. */
|
|
189
|
+
reset?: boolean;
|
|
178
190
|
/** Total unread in the folder right now. */
|
|
179
191
|
totalUnread: number;
|
|
180
192
|
}
|
package/lib/web.d.ts
CHANGED
|
@@ -40,6 +40,12 @@ export interface AccountCardData {
|
|
|
40
40
|
* exactly the state that has to be fixed before login can start.
|
|
41
41
|
*/
|
|
42
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;
|
|
43
49
|
/** Login state of an OAuth2 account: none / a device code in flight / logged in. */
|
|
44
50
|
oauthState: OAuth2State;
|
|
45
51
|
/** The mailbox address the stored token belongs to (OAuth2 accounts only). */
|
|
@@ -77,6 +83,23 @@ export interface AccountCardInput {
|
|
|
77
83
|
* 第三方应用注册,所以这是 OAuth2 账号的必填项,而设置面板是用户唯一的常规入口。
|
|
78
84
|
*/
|
|
79
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;
|
|
80
103
|
/**
|
|
81
104
|
* 认证方式覆盖,三态契约同上:undefined = 本卡片没提供(保留已存的 authKind 键),
|
|
82
105
|
* '' = 明确恢复「自动」(删掉该键,回到按 provider/主机派生),非空 = 钉住。
|
package/lib/web.js
CHANGED
|
@@ -87,6 +87,11 @@ function buildAccountCards(raw, defaultAccount, presets, tokens = () => ({ state
|
|
|
87
87
|
// back for the editor to prefill: an OAuth2 account without one cannot
|
|
88
88
|
// start a device-code login, and the card is where that gets fixed.
|
|
89
89
|
const clientId = typeof account.clientId === 'string' ? account.clientId.trim() : '';
|
|
90
|
+
// The display name and the login user are not secrets, so — like clientId —
|
|
91
|
+
// the card hands them back for the editor to prefill. A separate login
|
|
92
|
+
// password is a secret and only ever reported as a boolean.
|
|
93
|
+
const senderName = typeof account.senderName === 'string' ? account.senderName.trim() : '';
|
|
94
|
+
const authUser = typeof account.authUser === 'string' ? account.authUser.trim() : '';
|
|
90
95
|
const oauth = authKind === 'oauth2' ? tokens(name, user) : { state: 'none' };
|
|
91
96
|
list.push({
|
|
92
97
|
name,
|
|
@@ -97,6 +102,9 @@ function buildAccountCards(raw, defaultAccount, presets, tokens = () => ({ state
|
|
|
97
102
|
authKind,
|
|
98
103
|
...(pinned === 'oauth2' || pinned === 'password' ? { authKindDeclared: pinned } : {}),
|
|
99
104
|
...(clientId !== '' ? { clientId } : {}),
|
|
105
|
+
...(senderName !== '' ? { senderName } : {}),
|
|
106
|
+
...(authUser !== '' ? { authUser } : {}),
|
|
107
|
+
...(typeof account.authPassword === 'string' && account.authPassword !== '' ? { hasAuthPassword: true } : {}),
|
|
100
108
|
oauthState: oauth.state,
|
|
101
109
|
...(oauth.user !== undefined ? { oauthUser: oauth.user } : {}),
|
|
102
110
|
imap,
|
|
@@ -318,7 +326,7 @@ function persistedProvider(provider, customNames) {
|
|
|
318
326
|
* contract on AccountCardInput: the card omits the field whenever the editor has
|
|
319
327
|
* nothing to say about it, which must leave the stored secret untouched.
|
|
320
328
|
*/
|
|
321
|
-
function normalizeCardForYaml(card, customNames, inheritedPassword) {
|
|
329
|
+
function normalizeCardForYaml(card, customNames, inheritedPassword, inheritedAuthPassword) {
|
|
322
330
|
const out = {};
|
|
323
331
|
const provider = persistedProvider(card.provider, customNames);
|
|
324
332
|
if (provider !== undefined)
|
|
@@ -355,16 +363,40 @@ function normalizeCardForYaml(card, customNames, inheritedPassword) {
|
|
|
355
363
|
}
|
|
356
364
|
if (card.inboxFolder !== undefined)
|
|
357
365
|
out.inboxFolder = card.inboxFolder;
|
|
366
|
+
if (card.senderName !== undefined) {
|
|
367
|
+
const senderName = String(card.senderName).trim();
|
|
368
|
+
if (senderName !== '')
|
|
369
|
+
out.senderName = senderName;
|
|
370
|
+
}
|
|
371
|
+
if (card.authUser !== undefined) {
|
|
372
|
+
const authUser = String(card.authUser).trim();
|
|
373
|
+
if (authUser !== '')
|
|
374
|
+
out.authUser = authUser;
|
|
375
|
+
}
|
|
376
|
+
if (card.authPassword === undefined) {
|
|
377
|
+
// Same contract as password: a card that says nothing must not delete it.
|
|
378
|
+
if (inheritedAuthPassword !== undefined)
|
|
379
|
+
out.authPassword = inheritedAuthPassword;
|
|
380
|
+
}
|
|
381
|
+
else if (card.authPassword !== '') {
|
|
382
|
+
out.authPassword = String(card.authPassword);
|
|
383
|
+
}
|
|
358
384
|
return out;
|
|
359
385
|
}
|
|
360
|
-
/**
|
|
361
|
-
function
|
|
386
|
+
/** One secret key stored in the source YAML for one account, if it is there. */
|
|
387
|
+
function storedSecretOf(raw, name, key) {
|
|
362
388
|
const account = raw[name];
|
|
363
389
|
if (account === null || typeof account !== 'object' || Array.isArray(account))
|
|
364
390
|
return { present: false, value: undefined };
|
|
365
|
-
if (!Object.prototype.hasOwnProperty.call(account,
|
|
391
|
+
if (!Object.prototype.hasOwnProperty.call(account, key))
|
|
366
392
|
return { present: false, value: undefined };
|
|
367
|
-
return { present: true, value: account
|
|
393
|
+
return { present: true, value: account[key] };
|
|
394
|
+
}
|
|
395
|
+
function storedPasswordOf(raw, name) {
|
|
396
|
+
return storedSecretOf(raw, name, 'password');
|
|
397
|
+
}
|
|
398
|
+
function storedAuthPasswordOf(raw, name) {
|
|
399
|
+
return storedSecretOf(raw, name, 'authPassword');
|
|
368
400
|
}
|
|
369
401
|
/**
|
|
370
402
|
* Fallback writer: loses comments but keeps the semantics the cards describe.
|
|
@@ -390,6 +422,7 @@ function fallbackSerialize(cards, defaultAccount, source, customNames) {
|
|
|
390
422
|
for (const card of cards) {
|
|
391
423
|
const name = card.name;
|
|
392
424
|
let inherited;
|
|
425
|
+
let inheritedAuthPassword;
|
|
393
426
|
if (stored === undefined) {
|
|
394
427
|
// Nothing could be read back: a silent card may be losing a real secret.
|
|
395
428
|
if (card.password === undefined)
|
|
@@ -399,8 +432,11 @@ function fallbackSerialize(cards, defaultAccount, source, customNames) {
|
|
|
399
432
|
const { present, value } = storedPasswordOf(stored, name);
|
|
400
433
|
if (present)
|
|
401
434
|
inherited = value;
|
|
435
|
+
const auth = storedAuthPasswordOf(stored, name);
|
|
436
|
+
if (auth.present)
|
|
437
|
+
inheritedAuthPassword = auth.value;
|
|
402
438
|
}
|
|
403
|
-
raw[name] = normalizeCardForYaml(card, customNames, inherited);
|
|
439
|
+
raw[name] = normalizeCardForYaml(card, customNames, inherited, inheritedAuthPassword);
|
|
404
440
|
}
|
|
405
441
|
return {
|
|
406
442
|
accountsYaml: serializeAccountsYaml(raw, defaultAccount),
|
|
@@ -485,6 +521,26 @@ function serializeAccountsDraft(source, cards, defaultAccount, customNames) {
|
|
|
485
521
|
const nextProvider = persistedProvider(card.provider, customNames);
|
|
486
522
|
writeField(account, 'provider', nextProvider);
|
|
487
523
|
writeField(account, 'user', card.user);
|
|
524
|
+
// senderName / authUser are plain three-state fields (undefined = 保持原样,
|
|
525
|
+
// '' = 清除, 非空 = 写入) — writeField already implements exactly that.
|
|
526
|
+
// An undefined field means "this card says nothing" — writeField would read
|
|
527
|
+
// that as「delete」, so the guard has to live here, not inside it.
|
|
528
|
+
if (card.senderName !== undefined)
|
|
529
|
+
writeField(account, 'senderName', String(card.senderName).trim());
|
|
530
|
+
if (card.authUser !== undefined)
|
|
531
|
+
writeField(account, 'authUser', String(card.authUser).trim());
|
|
532
|
+
// authPassword is a secret and follows the password contract verbatim: the
|
|
533
|
+
// card never carries the plaintext, so an omitted field must leave the
|
|
534
|
+
// stored key — value, position and comment — untouched.
|
|
535
|
+
if (card.authPassword === undefined) {
|
|
536
|
+
// 未提供 = 保持原样:什么都不写。
|
|
537
|
+
}
|
|
538
|
+
else if (card.authPassword === '') {
|
|
539
|
+
account.delete('authPassword');
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
account.set('authPassword', String(card.authPassword));
|
|
543
|
+
}
|
|
488
544
|
// Password is three-state, unlike every other field: the card is never given
|
|
489
545
|
// the plaintext (snapshot exposes hasPassword only), so an omitted password
|
|
490
546
|
// means "the editor has nothing to say" and the stored key must survive
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-email",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"description": "DSH 邮件插件:IMAP/SMTP 收发搜索、回复转发、附件与整理,多账号卡片设置页 + Outlook OAuth2 + 发信审批。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"assets/whale-fallback.png",
|
|
20
20
|
"cordis.patch.yml",
|
|
21
21
|
"README.md",
|
|
22
|
-
"README.en.md"
|
|
22
|
+
"README.en.md",
|
|
23
|
+
"CHANGELOG.md"
|
|
23
24
|
],
|
|
24
25
|
"scripts": {
|
|
25
26
|
"build": "tsc",
|