dsh-email 0.14.0 → 0.14.1

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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.1(2026-09-27)
4
+
5
+ - 未补全的账号和服务器预设会显示未保存提示;避免草稿丢失和错误的“已保存”状态。测试连接只校验所选账号,错误直接指向设置页中的对应字段。
6
+
3
7
  > 完整历史(含详细改动说明)。README 只保留最近几个版本的一句话摘要。
4
8
 
5
9
  ## 中文版
package/README.en.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # dsh-email
2
2
 
3
+ ## 0.14.1 update (2026-09-27)
4
+
5
+ Incomplete account and server-preset drafts display an unsaved status and are protected against accidental navigation. Connection tests validate the selected account independently and identify the relevant settings field.
6
+
7
+ Validation host: Harness 0.1.7-rc.2 built from official sources, retaining the local tool-scheduler fix. Build and automated checks pass; interactive coverage and external-service limits are recorded in this release round.
8
+
3
9
  ![npm](https://img.shields.io/npm/v/dsh-email) ![downloads](https://img.shields.io/npm/dm/dsh-email) ![license](https://img.shields.io/github/license/STARDUSTLC666/dsh-email) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-email?style=social)
4
10
 
5
11
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
package/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  # dsh-email
4
4
 
5
+ ## 0.14.1 更新(2026-09-27)
6
+
7
+ 未补全的账号和服务器预设会显示未保存提示;避免草稿丢失和错误的“已保存”状态。测试连接只校验所选账号,错误直接指向设置页中的对应字段。
8
+
9
+ 验证宿主:官方源码构建的 Harness 0.1.7-rc.2(保留本地工具调度器修复)。构建与自动测试通过;实际操作和外部服务限制见本轮验收记录。
10
+
5
11
  > **让 agent 协助处理邮件**:收发、搜索、回复转发、附件、邮件整理与新邮件提醒,支持八种常见邮箱服务预设。
6
12
 
7
13
  ![npm version](https://img.shields.io/npm/v/dsh-email?label=npm&color=blue) ![npm downloads](https://img.shields.io/npm/dm/dsh-email) ![license](https://img.shields.io/npm/l/dsh-email) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-email?style=social)
package/lib/client.js CHANGED
@@ -208,6 +208,7 @@ const CSS = [
208
208
  ".dshe-auto-status{font-size:12px;font-weight:600;align-self:center;color:var(--dsw-alias-label-tertiary,#77736d)}",
209
209
  ".dshe-auto-status.is-saved{color:var(--dsw-alias-state-success-primary,#267d52)}",
210
210
  ".dshe-auto-status.is-error{color:var(--dsw-alias-state-error-primary,#aa3939)}",
211
+ ".dshe-auto-status.is-draft{color:var(--dsw-alias-state-warning-primary,#986000)}",
211
212
  ".dshe-acc-badge.is-oauth{border-color:transparent;background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#267d52) 12%,transparent);color:var(--dsw-alias-state-success-primary,#267d52)}",
212
213
  ".dshe-oauth{display:grid;gap:8px;padding:10px 11px;border:1px dashed var(--dsw-alias-border-l2,#dedbd5);border-radius:10px;background:var(--dsw-alias-bg-layer-1,#fff)}",
213
214
  ".dshe-oauth.is-logged-in{border-style:solid;border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary,#267d52) 34%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#267d52) 8%,transparent)}",
@@ -260,7 +261,7 @@ var UI = {
260
261
  "provider.outlook": "Outlook",
261
262
  "provider.icloud": "iCloud",
262
263
  "provider.custom": "自定义",
263
- "provider.noneInYaml": "(YAML 未指定服务商)",
264
+ "provider.noneInYaml": "请选择邮箱服务商",
264
265
  "provider.undefinedPresetSuffix": "(未定义预设)",
265
266
 
266
267
  // 全局设置
@@ -272,6 +273,7 @@ var UI = {
272
273
  // 自动保存状态条
273
274
  "status.saved": "已保存",
274
275
  "status.saving": "保存中…",
276
+ "status.draft": "还有未保存的内容,请补全下方标记的字段。",
275
277
  "status.saveFailed": "保存失败:{message}(点击重试)",
276
278
 
277
279
  // 账号卡片
@@ -314,7 +316,7 @@ var UI = {
314
316
  "card.passwordLabel": "授权码 / 应用专用密码",
315
317
  "card.passwordPlaceholder": "留空保持不变",
316
318
  "card.passwordSaved": "已存有授权码:留空保持不变,清空后填内容即覆盖。",
317
- "card.passwordHint": "留空即不写入 password 键。",
319
+ "card.passwordHint": "请填写邮箱服务商提供的授权码或应用专用密码,然后点击「测试连接」。",
318
320
  "card.senderNameLabel": "发件显示名(可选)",
319
321
  "card.senderNamePlaceholder": "只影响收件人看到的名称",
320
322
  "card.senderNameHint": "只改收件人看到的显示名,发件地址仍是上面的邮箱地址。",
@@ -439,7 +441,7 @@ var UI = {
439
441
  "provider.outlook": "Outlook",
440
442
  "provider.icloud": "iCloud",
441
443
  "provider.custom": "Custom",
442
- "provider.noneInYaml": "(no provider in YAML)",
444
+ "provider.noneInYaml": "Choose your email provider",
443
445
  "provider.undefinedPresetSuffix": "(undefined preset)",
444
446
 
445
447
  // 全局设置
@@ -451,6 +453,7 @@ var UI = {
451
453
  // 自动保存状态条
452
454
  "status.saved": "Saved",
453
455
  "status.saving": "Saving…",
456
+ "status.draft": "Some edits are not saved yet. Complete the highlighted fields below.",
454
457
  "status.saveFailed": "Save failed: {message} (click to retry)",
455
458
 
456
459
  // 账号卡片
@@ -493,7 +496,7 @@ var UI = {
493
496
  "card.passwordLabel": "Authorization code / app password",
494
497
  "card.passwordPlaceholder": "Leave empty to keep it",
495
498
  "card.passwordSaved": "A password is already stored: leave empty to keep it, or clear the field and type to overwrite.",
496
- "card.passwordHint": "Leave it empty and the password key is not written.",
499
+ "card.passwordHint": "Enter the authorization code or app password from your email provider, then click “Test connection”.",
497
500
  "card.senderNameLabel": "Sender display name (optional)",
498
501
  "card.senderNamePlaceholder": "Only the name recipients see",
499
502
  "card.senderNameHint": "Changes only the name recipients see; the address stays the one above.",
@@ -1350,33 +1353,38 @@ function AccountCardsEditor(props) {
1350
1353
  */
1351
1354
  const autoSave = (draftsNow, orderNow, defaultNow, immediate) => {
1352
1355
  if (typeof props.onAutoSave !== "function") return null;
1356
+ const incomplete = (text) => {
1357
+ setNotice({ kind: "error", text });
1358
+ if (typeof props.onDraftStatus === "function") props.onDraftStatus(true);
1359
+ };
1353
1360
  const names = orderNow.map((row) => row.key).filter((name) => Object.prototype.hasOwnProperty.call(draftsNow, name));
1354
1361
  let built;
1355
1362
  try {
1356
1363
  built = names.map((name) => draftToInput(draftsNow[name]));
1357
1364
  } catch (e) {
1358
- console.error("[dsh-email] 卡片快照组装失败", e, names, draftsNow);
1365
+ incomplete(e && e.message ? e.message : String(e));
1359
1366
  return null;
1360
1367
  }
1361
1368
  if (built.some((card) => String(card.name || "").trim() === "")) {
1362
- setNotice({ kind: "error", text: t("card.errNoName") });
1369
+ incomplete(t("card.errNoName"));
1363
1370
  return null;
1364
1371
  }
1365
1372
  if (names.some((name) => !draftsNow[name].provider && !(draftsNow[name].imap?.host && draftsNow[name].smtp?.host))) {
1366
1373
  // 服务商就是账号的连接身份:没有它,后端写回时会把 provider 键删掉,这个账号
1367
1374
  // 永远连不上。名字和它都属于「这还是一张半填的卡」,一起拦在这里。
1368
- setNotice({ kind: "error", text: t("card.errNoProvider") });
1375
+ incomplete(t("card.errNoProvider"));
1369
1376
  return null;
1370
1377
  }
1371
1378
  if (built.some((card) => card.name === "defaultAccount")) {
1372
- setNotice({ kind: "error", text: t("card.errReservedName") });
1379
+ incomplete(t("card.errReservedName"));
1373
1380
  return null;
1374
1381
  }
1375
1382
  if (built.length > 1 && String(defaultNow || "") === "") {
1376
- setNotice({ kind: "error", text: t("card.errNoDefault") });
1383
+ incomplete(t("card.errNoDefault"));
1377
1384
  return null;
1378
1385
  }
1379
1386
  setNotice(null);
1387
+ if (typeof props.onDraftStatus === "function") props.onDraftStatus(false);
1380
1388
  // 返回值只给 OAuth 登录用:它必须等这份配置真的落盘(后端按账号名去读 provider
1381
1389
  // 和端点)。其余调用方照旧忽略返回值。
1382
1390
  return props.onAutoSave(built, String(defaultNow || ""), immediate === true);
@@ -1889,9 +1897,11 @@ function ServerPresetsEditor(props) {
1889
1897
  const result = serializePresetDrafts(nextOrder.map((row) => row.key), nextDrafts);
1890
1898
  if (result.error !== undefined) {
1891
1899
  setNotice({ kind: "error", text: result.error });
1900
+ if (typeof props.onDraftStatus === "function") props.onDraftStatus(true);
1892
1901
  return;
1893
1902
  }
1894
1903
  setNotice(null);
1904
+ if (typeof props.onDraftStatus === "function") props.onDraftStatus(false);
1895
1905
  props.onAutoSave(result.text);
1896
1906
  };
1897
1907
 
@@ -2109,6 +2119,8 @@ function EmailSettingsSection() {
2109
2119
  const [error, setError] = useState("");
2110
2120
  /** 自动保存的可见状态:null 视同「已保存」(刚加载完、什么都没改)。 */
2111
2121
  const [saveState, setSaveState] = useState(null);
2122
+ const [incompleteDrafts, setIncompleteDrafts] = useState({ accounts: false, presets: false });
2123
+ const hasIncompleteDraft = incompleteDrafts.accounts || incompleteDrafts.presets;
2112
2124
  /** 卡片级提示(注释丢失、序列化失败…):走卡片自己的 notice 位,不打断顶部状态。 */
2113
2125
  const [cardsNotice, setCardsNotice] = useState(null);
2114
2126
  /**
@@ -2163,6 +2175,7 @@ function EmailSettingsSection() {
2163
2175
  accountsYamlRef.current = next.accountsYaml || "";
2164
2176
  savedSignatureRef.current = signatureOf(next, next.accountsYaml || "");
2165
2177
  setSaveState(null);
2178
+ setIncompleteDrafts({ accounts: false, presets: false });
2166
2179
  setCardsNotice(null);
2167
2180
  setCustomNames(presetNamesOf(next.serverPresets));
2168
2181
  setCardsDetail(snap.accountsDetail);
@@ -2184,6 +2197,7 @@ function EmailSettingsSection() {
2184
2197
  * 防抖那条路交不出 promise(定时器还没到点),返回 null。
2185
2198
  */
2186
2199
  const scheduleSave = (immediate) => {
2200
+ setSaveState({ kind: "busy" });
2187
2201
  // 用户又改了:还在飞的那次保存结果就作废,别拿旧结果盖住新状态。
2188
2202
  seqRef.current += 1;
2189
2203
  if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
@@ -2270,14 +2284,14 @@ function EmailSettingsSection() {
2270
2284
  // 有没落盘的内容(保存中或保存失败)才拦离开:这正是「编辑即保存」剩下的窗口。
2271
2285
  useEffect(() => {
2272
2286
  const guard = (e) => {
2273
- if (saveState === null || saveState.kind === "saved") return undefined;
2287
+ if (!hasIncompleteDraft && (saveState === null || saveState.kind === "saved")) return undefined;
2274
2288
  e.preventDefault();
2275
2289
  e.returnValue = "";
2276
2290
  return "";
2277
2291
  };
2278
2292
  window.addEventListener("beforeunload", guard);
2279
2293
  return () => window.removeEventListener("beforeunload", guard);
2280
- }, [saveState]);
2294
+ }, [saveState, hasIncompleteDraft]);
2281
2295
 
2282
2296
  /** 卡片序列化的往返序号:打字快时多个 serializeAccounts 会并发,只认最后一个。 */
2283
2297
  const cardsSeqRef = useRef(0);
@@ -2312,6 +2326,7 @@ function EmailSettingsSection() {
2312
2326
  */
2313
2327
  const onCardsAutoSave = async (cards, defaultAccount, immediate) => {
2314
2328
  const seq = ++cardsSeqRef.current;
2329
+ setSaveState({ kind: "busy" });
2315
2330
  try {
2316
2331
  const result = await serializeCards(cards, defaultAccount);
2317
2332
  if (seq !== cardsSeqRef.current) return { ok: false, reason: "superseded" }; // 更晚的一次已经在路上,别用旧结果盖新状态
@@ -2333,8 +2348,10 @@ function EmailSettingsSection() {
2333
2348
  if (flushed && typeof flushed.then === "function") await flushed;
2334
2349
  return { ok: true };
2335
2350
  } catch (e) {
2351
+ if (seq !== cardsSeqRef.current) return { ok: false, reason: "superseded" };
2336
2352
  const message = e && e.message ? e.message : String(e);
2337
2353
  setCardsNotice({ kind: "error", text: t("notice.cardsNotWritten", { message }) });
2354
+ setSaveState({ kind: "error", text: message });
2338
2355
  return { ok: false, reason: message };
2339
2356
  }
2340
2357
  };
@@ -2360,7 +2377,7 @@ function EmailSettingsSection() {
2360
2377
 
2361
2378
  const accounts = snapshot && snapshot.accounts ? snapshot.accounts : [];
2362
2379
  const presetsMerged = presetsWithNames((snapshot && snapshot.presets) || undefined, customNames);
2363
- const statusText = saveState === null
2380
+ const statusText = hasIncompleteDraft ? t("status.draft") : saveState === null
2364
2381
  ? t("status.saved")
2365
2382
  : saveState.kind === "busy"
2366
2383
  ? t("status.saving")
@@ -2368,9 +2385,11 @@ function EmailSettingsSection() {
2368
2385
  ? t("status.saved")
2369
2386
  : t("status.saveFailed", { message: saveState.text });
2370
2387
  const status = h("span", {
2371
- className: "dshe-auto-status" + (saveState === null ? "" : " is-" + saveState.kind),
2372
- onClick: saveState !== null && saveState.kind === "error" ? () => flushSave() : undefined,
2373
- style: saveState !== null && saveState.kind === "error" ? { cursor: "pointer" } : undefined,
2388
+ role: "status",
2389
+ "aria-live": "polite",
2390
+ className: "dshe-auto-status" + (hasIncompleteDraft ? " is-draft" : saveState === null ? "" : " is-" + saveState.kind),
2391
+ onClick: !hasIncompleteDraft && saveState !== null && saveState.kind === "error" ? () => flushSave() : undefined,
2392
+ style: !hasIncompleteDraft && saveState !== null && saveState.kind === "error" ? { cursor: "pointer" } : undefined,
2374
2393
  }, statusText);
2375
2394
 
2376
2395
  return h("div", { className: "dshe-settings" }, [
@@ -2403,6 +2422,7 @@ function EmailSettingsSection() {
2403
2422
  presets: presetsMerged,
2404
2423
  extraNotice: cardsNotice,
2405
2424
  onAutoSave: onCardsAutoSave,
2425
+ onDraftStatus: (blocked) => setIncompleteDrafts((current) => ({ ...current, accounts: blocked })),
2406
2426
  onTest: onCardTest,
2407
2427
  // OAuth2 登录成功后重拉整份快照:卡片的 authKind/oauthState/oauthUser 是
2408
2428
  // 服务端算出来的,只有新快照才拿得到(和加载走同一条路径)。
@@ -2413,6 +2433,7 @@ function EmailSettingsSection() {
2413
2433
  h(ServerPresetsEditor, {
2414
2434
  presets: presetsAtLoad,
2415
2435
  onAutoSave: (text) => update({ serverPresets: typeof text === "string" ? text : "" }),
2436
+ onDraftStatus: (blocked) => setIncompleteDrafts((current) => ({ ...current, presets: blocked })),
2416
2437
  }),
2417
2438
  ]),
2418
2439
  snapshot && snapshot.writable === false
package/lib/config.d.ts CHANGED
@@ -256,8 +256,10 @@ export declare function serializeAccountsYaml(raw: unknown, defaultAccount?: str
256
256
  * Resolve and validate the raw row config. Throws with an actionable message
257
257
  * (in Chinese, since it is what the user and the model both read) when the
258
258
  * account is not fully specified.
259
+ * An explicit account resolves only that card for connection tests, without
260
+ * making named accounts eligible for the single-account environment password.
259
261
  */
260
- export declare function resolveEmailSettings(config: EmailConfig | undefined): ResolvedEmailSettings;
262
+ export declare function resolveEmailSettings(config: EmailConfig | undefined, onlyAccount?: string): ResolvedEmailSettings;
261
263
  /** Every name a `provider:` may legally use, built-ins first. */
262
264
  export declare function providerNames(custom?: Record<string, ServerPreset>): string[];
263
265
  /**
package/lib/config.js CHANGED
@@ -201,8 +201,10 @@ function normalizeAccountForYaml(value) {
201
201
  * Resolve and validate the raw row config. Throws with an actionable message
202
202
  * (in Chinese, since it is what the user and the model both read) when the
203
203
  * account is not fully specified.
204
+ * An explicit account resolves only that card for connection tests, without
205
+ * making named accounts eligible for the single-account environment password.
204
206
  */
205
- export function resolveEmailSettings(config) {
207
+ export function resolveEmailSettings(config, onlyAccount) {
206
208
  const raw = config ?? {};
207
209
  const common = {
208
210
  provider: raw.provider,
@@ -218,6 +220,10 @@ export function resolveEmailSettings(config) {
218
220
  const entries = parsedYaml !== undefined
219
221
  ? parsedYaml.map
220
222
  : (raw.accounts === undefined || Object.keys(raw.accounts).length === 0 ? undefined : raw.accounts);
223
+ const available = entries === undefined ? ['default'] : Object.keys(entries);
224
+ if (onlyAccount !== undefined && !available.includes(onlyAccount)) {
225
+ throw new Error(`未知账号 "${onlyAccount}",可用:${available.join('、')}`);
226
+ }
221
227
  // The custom preset table is read once, here, and never stored on the result:
222
228
  // it is a lookup source for provider names, not part of the resolved config.
223
229
  // A broken preset text degrades to "no custom presets" — the account-level
@@ -237,11 +243,16 @@ export function resolveEmailSettings(config) {
237
243
  }
238
244
  else {
239
245
  for (const [name, acc] of Object.entries(entries)) {
246
+ if (onlyAccount !== undefined && name !== onlyAccount)
247
+ continue;
240
248
  accounts.set(name, resolveAccount(name, common, acc ?? {}, false, providers, known));
241
249
  }
242
250
  }
243
251
  let defaultName;
244
- if (raw.defaultAccount !== undefined && raw.defaultAccount !== '') {
252
+ if (onlyAccount !== undefined) {
253
+ defaultName = onlyAccount;
254
+ }
255
+ else if (raw.defaultAccount !== undefined && raw.defaultAccount !== '') {
245
256
  if (!accounts.has(raw.defaultAccount)) {
246
257
  throw new Error(`dsh-email:defaultAccount "${raw.defaultAccount}" 不存在,可用账号:${[...accounts.keys()].join('、')}`);
247
258
  }
@@ -343,7 +354,7 @@ function resolveAccount(name, common, acc, allowEnvPassword, providers, known) {
343
354
  };
344
355
  const problems = [];
345
356
  if (user === '')
346
- problems.push(`账号 "${name}" 的 user(邮箱地址)未填写`);
357
+ problems.push(`账号 "${name}" 的邮箱地址未填写`);
347
358
  // An explicit `authKind` outranks the derivation: a tenant that still accepts
348
359
  // an app password for Exchange Online, or a mailbox that worked before the
349
360
  // derivation existed, keeps working instead of being told「尚未登录」.
@@ -358,14 +369,14 @@ function resolveAccount(name, common, acc, allowEnvPassword, providers, known) {
358
369
  // in the OAuth2 store, and requiring a password would demand a secret
359
370
  // Microsoft no longer accepts for Exchange Online.
360
371
  if (!oauth2 && authPassword === '') {
361
- problems.push(`账号 "${name}" 的 ${authUser === user ? 'password' : 'authPassword'} 未填写(单账号可用环境变量 ${EMAIL_PASSWORD_ENV})`);
372
+ problems.push(`账号 "${name}" 的${authUser === user ? '授权码 / 应用专用密码' : '登录账号的密码'}未填写`);
362
373
  }
363
374
  if (imap.host === undefined || imap.host === '')
364
375
  problems.push(`账号 "${name}" 的 imap.host 未填写(可填 provider 预设:${known.join('/')})`);
365
376
  if (smtp.host === undefined || smtp.host === '')
366
377
  problems.push(`账号 "${name}" 的 smtp.host 未填写(同上)`);
367
378
  if (problems.length > 0) {
368
- throw new Error(`dsh-email 未配置:${problems.join(';')}。请在 profile 的 cordis.patch.yml 中覆盖 tool-email 行并重启(见插件 README)`);
379
+ throw new Error(`邮件账号配置不完整:${problems.join(';')}。请在「设置 → 邮件」补全对应账号,再点击「测试连接」。修改会自动保存并立即生效。`);
369
380
  }
370
381
  const clientId = (acc.clientId ?? common.clientId ?? '').trim();
371
382
  return {
package/lib/web.js CHANGED
@@ -855,12 +855,12 @@ export class EmailSettingsBackend {
855
855
  // does for the stored settings (it never enters the resolved fingerprint).
856
856
  const draft = toEmailConfig(value, null);
857
857
  const presets = value?.serverPresets ?? this.scope.get()?.serverPresets;
858
+ const requested = typeof accountName === 'string' && accountName.trim() !== '' ? accountName.trim() : '';
858
859
  const settings = resolveEmailSettings({
859
860
  ...this.currentConfig,
860
861
  ...draft,
861
862
  ...(typeof presets === 'string' ? { serverPresets: presets } : {}),
862
- });
863
- const requested = typeof accountName === 'string' && accountName.trim() !== '' ? accountName.trim() : '';
863
+ }, requested || undefined);
864
864
  const available = [...settings.accounts.keys()];
865
865
  const name = requested !== '' ? requested : settings.defaultAccount;
866
866
  const cfg = settings.accounts.get(name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-email",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "DSH 邮件插件:IMAP/SMTP 收发搜索、回复转发、附件与整理,多账号卡片设置页、Outlook OAuth2 登录与发信审批。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",