dsh-email 0.17.2 → 0.18.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/client.js CHANGED
@@ -12,10 +12,10 @@ const h = React.createElement;
12
12
  const __FR_KEY = 'dsh-first-run:dsh-email:v1';
13
13
  const __FR_TITLE = '邮件 · 首次使用(4 步)';
14
14
  const __FR_STEPS = [
15
- { title: '先把账号填上', body: '下面填 IMAP / SMTP 地址与授权码(QQ 邮箱用「授权码」,不是登录密码)。保存后这里会显示连接状态。' },
16
- { title: '看一眼收件箱', body: '连接成功后,下方会列出最近邮件的标题与发件人;点开任意一封可读正文。' },
15
+ { title: '先把账号填上', body: '选择邮箱服务商或 AgentMail,填写对应授权码、登录信息或 API Key,再测试连接。' },
16
+ { title: '看一眼收件箱', body: '连接成功后切到「收发邮件」,选择账号与文件夹;点开一封邮件即可阅读正文。' },
17
17
  { title: '让它替你整理', body: '在对话里直接说你要做的事,比在页面里逐个点更快。', tryIt: '把今天的未读邮件按主题归纳一下' },
18
- { title: '发信前一定先看草稿', body: '涉及发信时默认先生成草稿给你确认,确认后才真正发送;收件人规则在下方「发信规则」里配。' },
18
+ { title: '发信前先看草稿', body: '用「写邮件」或「回复到草稿」准备内容,核对后再确认发送;模型发送工具仍遵守审批设置,收件人规则在对应页配置。' },
19
19
  ];
20
20
 
21
21
  // ---- first-run guide (shared snippet, keep in sync across plugins) ----
@@ -53,19 +53,20 @@ function __FirstRunCard() {
53
53
  const [index, setIndex] = React.useState(0);
54
54
  React.useEffect(() => { __frEnsureCss(); }, []);
55
55
  if (done) return null;
56
- const step = __FR_STEPS[index];
57
- const last = index >= __FR_STEPS.length - 1;
56
+ const steps = UI_LANG === "en" ? [{title:"Add an account",body:"Choose your mail provider or AgentMail and enter its credentials. Test the connection before browsing."},{title:"Open your mailbox",body:"Use Mailbox to browse Inbox and Sent, search, and read a message."},{title:"Let DSH organize mail",body:"Ask DSH to summarize unread mail or find a message."},{title:"Review before sending",body:"Compose or reply in Drafts. Review recipients, content and attachments, then confirm sending."}] : __FR_STEPS;
57
+ const step = steps[index];
58
+ const last = index >= steps.length - 1;
58
59
  const close = () => { __frWrite(); setDone(true); };
59
60
  return h('div', { className: 'fr-card' },
60
- h('div', { className: 'fr-head' }, h('span', { className: 'fr-dot' }), h('span', { className: 'fr-title' }, __FR_TITLE), h('span', { className: 'fr-count' }, (index + 1) + ' / ' + __FR_STEPS.length)),
61
+ h('div', { className: 'fr-head' }, h('span', { className: 'fr-dot' }), h('span', { className: 'fr-title' }, UI_LANG === 'en' ? 'Email · Getting started (4 steps)' : __FR_TITLE), h('span', { className: 'fr-count' }, (index + 1) + ' / ' + steps.length)),
61
62
  h('div', { className: 'fr-step' }, step.title),
62
63
  h('div', { className: 'fr-body' }, step.body),
63
64
  step.tryIt ? h('div', { className: 'fr-try' }, h('span', null, '可以直接说'), h('code', null, '「' + step.tryIt + '」')) : null,
64
65
  h('div', { className: 'fr-bar' },
65
- h('div', { className: 'fr-progress' }, __FR_STEPS.map((_, i) => h('span', { key: i, className: 'fr-seg' + (i <= index ? ' on' : '') }))),
66
- h('button', { className: 'fr-btn ghost', type: 'button', onClick: close }, '跳过'),
67
- index > 0 ? h('button', { className: 'fr-btn', type: 'button', onClick: () => setIndex(index - 1) }, '上一步') : null,
68
- h('button', { className: 'fr-btn primary', type: 'button', onClick: () => { if (last) close(); else setIndex(index + 1); } }, last ? '开始使用' : '下一步')));
66
+ h('div', { className: 'fr-progress' }, steps.map((_, i) => h('span', { key: i, className: 'fr-seg' + (i <= index ? ' on' : '') }))),
67
+ h('button', { className: 'fr-btn ghost', type: 'button', onClick: close }, UI_LANG === 'en' ? 'Skip' : '跳过'),
68
+ index > 0 ? h('button', { className: 'fr-btn', type: 'button', onClick: () => setIndex(index - 1) }, UI_LANG === 'en' ? 'Previous' : '上一步') : null,
69
+ h('button', { className: 'fr-btn primary', type: 'button', onClick: () => { if (last) close(); else setIndex(index + 1); } }, last ? (UI_LANG === 'en' ? 'Start' : '开始使用') : (UI_LANG === 'en' ? 'Next' : '下一步'))));
69
70
  }
70
71
  function __withFirstRun(Component) {
71
72
  return function FirstRunWrapped(props) {
@@ -92,7 +93,8 @@ function emailError(error) {
92
93
  if (/预览已过期|draft-preview-expired/.test(message + ' ' + (error?.code || ''))) return 'The send review expired or was already used. Review the current draft again.';
93
94
  if (/改变|已更新|冲突|重新加载/.test(message)) return 'The draft, account or rules changed. Reload the latest version and review your input before retrying.';
94
95
  if (/请先填写并保存邮件主题/.test(message)) return 'Fill in and save the email subject first.';
95
- if (/附件/.test(message)) return 'Check the attachments. Save the draft first; use up to 10 readable files and 20 MiB total. Remove invalid files and add them again.';
96
+ if (/附件/.test(message)) return 'Check the attachments and the selected account limit (up to 4 MiB for AgentMail, 20 MiB for IMAP). Save first, use at most 10 files, and remove unreadable or oversized files.';
97
+ if (/AgentMail/.test(message)) { const status = message.match(/HTTP \d+/)?.[0]; return 'AgentMail request failed' + (status ? ' (' + status + ')' : '') + '. Check the API region, key, inbox and service status. For sending, check Sent before trying again.'; }
96
98
  if (/账号|配置不完整|尚未登录|OAuth|设备码/.test(message)) return 'Check the selected email account. Complete its credentials or OAuth sign-in in Account settings, then test the connection.';
97
99
  if (/收件人|邮箱地址/.test(message)) return 'Check the recipient addresses and trusted-recipient rules before reviewing the draft again.';
98
100
  return 'The email operation failed. Check the account settings and DSH logs, then retry. For a send operation, check the mailbox before retrying.';
@@ -900,6 +902,7 @@ function draftsFromCards(list) {
900
902
  */
901
903
  function providerOptions(presets) {
902
904
  const out = PROVIDERS.map(([value, labelKey]) => ({ value, label: t(labelKey) }));
905
+ out.push({value:"agentmail",label:"AgentMail (US API)"},{value:"agentmail-eu",label:"AgentMail (EU API)"});
903
906
  const custom = (presets && presets.custom) || {};
904
907
  for (const name of Object.keys(custom)) {
905
908
  if (name === "") continue;
@@ -1017,6 +1020,7 @@ function renameDrafts(drafts, oldName, nextName) {
1017
1020
  * presets.custom(值还是 undefined,端点要等下一次快照),它已经是合法预设了。
1018
1021
  */
1019
1022
  function accountSummary(draft, presets) {
1023
+ if(draft.provider==="agentmail"||draft.provider==="agentmail-eu")return {label:draft.provider==="agentmail-eu"?"AgentMail EU":"AgentMail US",isCustom:false};
1020
1024
  const provider = draft.provider || "";
1021
1025
  const entry = PROVIDERS.filter(([value]) => value === provider)[0];
1022
1026
  if (entry) return { label: t(entry[1]), isCustom: false };
@@ -1039,6 +1043,7 @@ function presetEndpointText(endpoint) {
1039
1043
  * 保存后的新快照优先,避免自定义预设更新后继续显示旧地址。
1040
1044
  */
1041
1045
  function providerMetaText(draft, presets, cards) {
1046
+ if(draft.provider==="agentmail"||draft.provider==="agentmail-eu")return draft.provider==="agentmail-eu"?"AgentMail · api.agentmail.eu":"AgentMail · api.agentmail.to";
1042
1047
  const provider = String(draft.provider || "").trim();
1043
1048
  const saved = (cards || []).find(card => card.name === draft.name)
1044
1049
  || (cards || []).find(card => card.name === draft.originalName);
@@ -1722,7 +1727,7 @@ function AccountCardsEditor(props) {
1722
1727
  setRowStatus((cur) => Object.assign({}, cur, {
1723
1728
  [draftName]: {
1724
1729
  kind: "success",
1725
- text: t("card.testOk", { ms: result.ms, host: result.imapHost, port: result.imapPort }),
1730
+ text: result.apiHost ? dt("API 连接成功 · ","API connected · ")+result.apiHost : t("card.testOk", { ms: result.ms, host: result.imapHost, port: result.imapPort }),
1726
1731
  },
1727
1732
  }));
1728
1733
  } catch (e) {
@@ -1866,7 +1871,7 @@ function AccountCardsEditor(props) {
1866
1871
  h("label", null, t("card.addressLabel")),
1867
1872
  fieldInput("text", draft.user, (v) => patchDraft(name, { user: v }), "you@example.com"),
1868
1873
  ]),
1869
- h("div", { className: "dshe-field" }, [
1874
+ !draft.provider?.startsWith("agentmail") && h("div", { className: "dshe-field" }, [
1870
1875
  h("label", null, t("card.senderNameLabel")),
1871
1876
  fieldInput("text", draft.senderName, (v) => patchDraft(name, { senderName: v }), t("card.senderNamePlaceholder")),
1872
1877
  h("div", { className: "dshe-hint" }, t("card.senderNameHint")),
@@ -1912,32 +1917,32 @@ function AccountCardsEditor(props) {
1912
1917
  })
1913
1918
  : h(React.Fragment, null, [
1914
1919
  h("div", { className: "dshe-field" }, [
1915
- h("label", null, t("card.passwordLabel")),
1920
+ h("label", null, draft.provider?.startsWith("agentmail") ? "AgentMail API Key" : t("card.passwordLabel")),
1916
1921
  h("input", {
1917
1922
  type: "password",
1918
1923
  value: draft.password,
1919
1924
  disabled: busy !== "",
1920
- placeholder: t("card.passwordPlaceholder"),
1925
+ placeholder: draft.provider?.startsWith("agentmail") ? dt("输入 API Key;留空保留已存密钥", "API key; leave blank to retain saved key") : t("card.passwordPlaceholder"),
1921
1926
  onChange: (e) => patchDraft(name, { password: e.target.value }),
1922
1927
  }),
1923
1928
  h("div", { className: "dshe-hint" },
1924
1929
  view.hasPassword
1925
1930
  ? t("card.passwordSaved")
1926
- : t("card.passwordHint")),
1931
+ : draft.provider?.startsWith("agentmail") ? dt("从 AgentMail 控制台取得 API Key。API Key 保存在本机账号配置中,由 DSH 后端调用所选区域的 API。", "Use an API key from the AgentMail console. The key is stored in the local account configuration; the DSH backend calls the selected region.") : t("card.passwordHint")),
1927
1932
  ]),
1928
1933
  // 别名/中继:登录名与登录密码都只在密码类账号上有意义(OAuth2 用 token 登录)。
1929
- h("div", { className: "dshe-field" }, [
1934
+ !draft.provider?.startsWith("agentmail") && h("div", { className: "dshe-field" }, [
1930
1935
  h("label", null, t("card.authUserLabel")),
1931
1936
  fieldInput("text", draft.authUser, (v) => patchDraft(name, { authUser: v }), t("card.authUserPlaceholder")),
1932
1937
  h("div", { className: "dshe-hint" }, t("card.authUserHint")),
1933
1938
  ]),
1934
- h("div", { className: "dshe-field" }, [
1939
+ !draft.provider?.startsWith("agentmail") && h("div", { className: "dshe-field" }, [
1935
1940
  h("label", null, t("card.authPasswordLabel")),
1936
1941
  h("input", {
1937
1942
  type: "password",
1938
1943
  value: draft.authPassword,
1939
1944
  disabled: busy !== "",
1940
- placeholder: t("card.passwordPlaceholder"),
1945
+ placeholder: draft.provider?.startsWith("agentmail") ? dt("输入 API Key;留空保留已存密钥", "API key; leave blank to retain saved key") : t("card.passwordPlaceholder"),
1941
1946
  onChange: (e) => patchDraft(name, { authPassword: e.target.value }),
1942
1947
  }),
1943
1948
  h("div", { className: "dshe-hint" },
@@ -1946,7 +1951,7 @@ function AccountCardsEditor(props) {
1946
1951
  : t("card.authPasswordHint")),
1947
1952
  ]),
1948
1953
  ]),
1949
- h("div", { className: "dshe-field" }, [
1954
+ !draft.provider?.startsWith("agentmail") && h("div", { className: "dshe-field" }, [
1950
1955
  h("label", null, t("card.inboxLabel")),
1951
1956
  fieldInput("text", draft.inboxFolder, (v) => patchDraft(name, { inboxFolder: v }), "INBOX"),
1952
1957
  ]),
@@ -2228,6 +2233,93 @@ function ServerPresetsEditor(props) {
2228
2233
  ]);
2229
2234
  }
2230
2235
 
2236
+ const MAILBOX_ROUTE = "api/dsh-email/mailbox";
2237
+ async function mailboxApi(action, payload = {}, signal) {
2238
+ const response = await fetch(MAILBOX_ROUTE, { credentials: "same-origin", method: "POST", headers: { "Content-Type": "application/json", "x-dsh-email-mailbox": "1" }, body: JSON.stringify({ ...payload, action }), signal });
2239
+ if (action === "download" && response.ok) return response.blob();
2240
+ const result = await response.json();
2241
+ if (!response.ok || !result.ok) throw new Error(result.error?.message || dt("邮箱操作失败", "Mailbox action failed"));
2242
+ return result.value;
2243
+ }
2244
+ function MailboxWorkbench({ active, onConfigure, onDraft }) {
2245
+ const [accounts, setAccounts] = useState([]), [account, setAccount] = useState(""), [folders, setFolders] = useState([]), [folder, setFolder] = useState("");
2246
+ const [input, setInput] = useState(""), [query, setQuery] = useState(""), [offset, setOffset] = useState(0), [scanOffset, setScanOffset] = useState(0), [unread, setUnread] = useState(false), [refresh, setRefresh] = useState(0);
2247
+ const [result, setResult] = useState(null), [selected, setSelected] = useState(null), [detail, setDetail] = useState(null), [loading, setLoading] = useState(false), [reading, setReading] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState("");
2248
+ const busyRef = useRef(false), context = useRef(0);
2249
+ useEffect(() => {
2250
+ if (!document.getElementById("dshe-mailbox-css")) { const css = document.createElement("style"); css.id = "dshe-mailbox-css"; css.textContent = ".dshe-mail-toolbar{display:flex;flex-wrap:wrap;gap:10px;align-items:end}.dshe-mail-toolbar .dshe-field{flex:1 1 160px;margin:0}.dshe-mail-search{display:flex;gap:8px;flex:1 1 260px;align-items:end}.dshe-mail-search input[type=search]{min-width:0;width:100%}.dshe-mail-search>.dshe-field{flex:1 1 180px;min-width:120px}.dshe-mail-search .dshe-check{flex:0 0 auto;white-space:nowrap;display:flex;align-items:center;gap:6px}.dshe-mail-search input[type=checkbox]{width:auto}.dshe-mail-grid{display:grid;grid-template-columns:minmax(220px,1fr) minmax(0,1.5fr);gap:12px;margin-top:16px}.dshe-mail-list{display:flex;flex-direction:column;gap:6px;max-height:60vh;overflow:auto;min-width:0}.dshe-mail-row{display:block;width:100%;text-align:left;padding:12px;border-radius:10px;border:1px solid rgba(128,128,128,.3);background:transparent;color:inherit;cursor:pointer}.dshe-mail-row[aria-pressed=true]{background:rgba(80,130,220,.13);outline:2px solid rgba(80,130,220,.45);outline-offset:-2px}.dshe-mail-row strong,.dshe-mail-row small{display:block;overflow-wrap:anywhere}.dshe-mail-row small{opacity:.75;margin-top:5px}.dshe-mail-detail{min-width:0;padding:14px;border:1px solid rgba(128,128,128,.3);border-radius:12px}.dshe-mail-detail h3,.dshe-mail-detail p{overflow-wrap:anywhere}.dshe-mail-detail pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:55vh;overflow:auto;font:inherit;line-height:1.7;border-top:1px solid rgba(128,128,128,.25);padding-top:12px}.dshe-mail-meta{font-size:12px;opacity:.8}.dshe-mail-footer{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:12px}.dshe-mail-empty{padding:24px 10px;opacity:.75}.dshe-tabs{flex-wrap:wrap}@media(max-width:760px){.dshe-mail-grid{grid-template-columns:minmax(0,1fr)}.dshe-mail-list{max-height:35vh}.dshe-mail-detail pre{max-height:none}.dshe-mail-search{flex-wrap:wrap}}"; document.head.appendChild(css); }
2251
+ }, []);
2252
+ useEffect(() => {
2253
+ if (!active) return;
2254
+ const ctrl = new AbortController(); setError("");
2255
+ mailboxApi("accounts", {}, ctrl.signal).then(value => { if(ctrl.signal.aborted)return;setAccounts(value.accounts);setAccount(old => value.accounts.some(a => a.name === old) ? old : value.defaultAccount); }).catch(e => {if(!ctrl.signal.aborted)setError(emailError(e));});
2256
+ return () => ctrl.abort();
2257
+ }, [active]);
2258
+ useEffect(() => {
2259
+ context.current++; setSelected(null); setDetail(null); setResult(null); setFolder(""); setFolders([]); setOffset(0); setScanOffset(0);
2260
+ if (!active || !account) return;
2261
+ const ctrl = new AbortController();
2262
+ mailboxApi("folders", {account}, ctrl.signal).then(value => { if(ctrl.signal.aborted)return;setFolders(value.folders);setFolder(value.folders.find(f => f.specialUse === "\\Inbox")?.path || value.folders.find(f => f.path === "INBOX")?.path || value.folders[0]?.path || ""); }).catch(e=>{if(!ctrl.signal.aborted)setError(emailError(e));});
2263
+ return () => ctrl.abort();
2264
+ }, [active, account]);
2265
+ useEffect(() => {
2266
+ context.current++; setSelected(null); setDetail(null); setResult(null);
2267
+ if(!active || !account || !folder)return;
2268
+ const ctrl=new AbortController();setLoading(true);setError("");
2269
+ mailboxApi("list", {account,folder,query,offset,unreadOnly:unread,headerScanOffset:scanOffset}, ctrl.signal).then(value=>{if(!ctrl.signal.aborted)setResult(value);}).catch(e=>{if(!ctrl.signal.aborted)setError(emailError(e));}).finally(()=>{if(!ctrl.signal.aborted)setLoading(false);});
2270
+ return ()=>ctrl.abort();
2271
+ },[active,account,folder,query,offset,scanOffset,unread,refresh]);
2272
+ useEffect(()=>{
2273
+ setDetail(null); if(!active || selected===null)return;
2274
+ const ctrl=new AbortController();setReading(true);setError("");
2275
+ mailboxApi("read",{account,folder,uid:selected},ctrl.signal).then(value=>{if(!ctrl.signal.aborted)setDetail(value);}).catch(e=>{if(!ctrl.signal.aborted)setError(emailError(e));}).finally(()=>{if(!ctrl.signal.aborted)setReading(false);});
2276
+ return ()=>ctrl.abort();
2277
+ },[active,account,folder,selected]);
2278
+ const run=async task=>{if(busyRef.current)return;busyRef.current=true;setBusy(true);setError("");const generation=context.current;try{await task(()=>generation===context.current);}catch(e){if(generation===context.current)setError(emailError(e));}finally{busyRef.current=false;setBusy(false);}};
2279
+ const reset=()=>{setOffset(0);setScanOffset(0);setSelected(null);setDetail(null);};
2280
+ const changeFolder=value=>{reset();setFolder(value);};
2281
+ const who=list=>(list||[]).map(a=>a.name&&a.address?a.name+" <"+a.address+">":a.address||a.name).filter(Boolean).join(", ");
2282
+ const date=value=>{const d=new Date(value);return Number.isNaN(d.getTime())?value:d.toLocaleString(UI_LANG==="en"?"en-US":"zh-CN");};
2283
+ const rows=result?.messages||[], currentRow=rows.find(m=>m.uid===selected), hasMore=result?.hasMore??(result?.headerScan&&result?.searchSource==="local-headers"?offset+rows.length<result.count:result?.bodyScan?rows.length===20:offset+rows.length<(result?.count||0));
2284
+ const folderButton=(special,zh,en)=>{const target=folders.find(f=>f.specialUse===special)||folders.find(f=>special==="\\Sent"?/^(sent|sent messages|已发送)$/i.test(f.path):f.path==="INBOX");return target?h("button",{type:"button",className:"dshe-btn",disabled:busy||loading,"aria-pressed":folder===target.path,onClick:()=>changeFolder(target.path)},dt(zh,en)):null;};
2285
+ return h("section",{className:"dshe-panel","aria-label":dt("邮箱工作台","Mailbox workbench")},[
2286
+ h("div",{className:"dshe-mail-toolbar"},[
2287
+ h("div",{className:"dshe-field"},[h("label",{htmlFor:"dshe-mail-account"},dt("邮箱账号","Account")),h("select",{id:"dshe-mail-account",value:account,disabled:busy,onChange:e=>{reset();setInput("");setQuery("");setAccount(e.target.value);}},accounts.map(a=>h("option",{key:a.name,value:a.name},a.name+" · "+a.user)))]),
2288
+ h("div",{className:"dshe-field"},[h("label",{htmlFor:"dshe-mail-folder"},dt("文件夹","Folder")),h("select",{id:"dshe-mail-folder",value:folder,disabled:busy||!folders.length,onChange:e=>changeFolder(e.target.value)},folders.map(f=>h("option",{key:f.path,value:f.path},f.name)))]),
2289
+ h("button",{type:"button",className:"dshe-btn primary",disabled:busy||!account,onClick:()=>run(async valid=>{const draft=await draftApi("create",{account,to:"",cc:"",subject:"",text:""});if(valid())onDraft(draft.id);})},dt("写邮件","Compose")),
2290
+ h("button",{type:"button",className:"dshe-btn",disabled:loading||busy||!account,onClick:()=>setRefresh(n=>n+1)},dt("刷新邮件","Refresh mail")),
2291
+ ]),
2292
+ h("form",{className:"dshe-mail-search",onSubmit:e=>{e.preventDefault();reset();setUnread(false);setQuery(input.trim());setRefresh(n=>n+1);}},[
2293
+ h("div",{className:"dshe-field"},[h("label",{htmlFor:"dshe-mail-search"},dt("搜索当前文件夹","Search current folder")),h("input",{id:"dshe-mail-search",type:"search",value:input,maxLength:500,disabled:busy,onChange:e=>setInput(e.target.value)})]),
2294
+ h("button",{type:"submit",className:"dshe-btn",disabled:loading||busy||!folder},dt("搜索","Search")),
2295
+ query?h("button",{type:"button",className:"dshe-btn",disabled:busy,onClick:()=>{reset();setInput("");setQuery("");}},dt("清除搜索","Clear search")):null,
2296
+ h("label",{className:"dshe-check"},[h("input",{type:"checkbox",checked:unread,disabled:busy||!!query,onChange:e=>{reset();setUnread(e.target.checked);}}),dt("只看未读","Unread only")]),
2297
+ ]),
2298
+ h("div",{className:"dshe-actions"},[folderButton("\\Inbox","收件箱","Inbox"),folderButton("\\Sent","已发送","Sent")]),
2299
+ error?h("div",{className:"dshe-alert error",role:"alert"},[error," ",h("button",{type:"button",className:"dshe-btn",onClick:onConfigure},dt("检查账号设置","Check account settings"))]):null,
2300
+ h("div",{role:"status","aria-live":"polite",className:"dshe-hint"},loading?dt("正在读取邮件…","Loading messages…"):result?dt("本页 ","This page: ")+rows.length+dt(" 封"," messages")+(result.order==="relevance"?dt(" · 按相关性排序"," · Ranked by relevance"):dt(" · 按邮箱到达序排列"," · Mailbox arrival order")):dt("在账号设置中添加并测试邮箱后,即可浏览邮件。","Add and test an account in settings to browse mail.")),
2301
+ result?.headerScan?h("p",{className:"dshe-hint"},dt("信封实际扫描:","Headers scanned: ")+result.headerScan.scanned+" / "+result.headerScan.mailboxTotal+(result.headerScan.complete?dt(",信封覆盖完整;正文另有限制。","; headers fully covered; body search has a separate limit."):dt(",尚未覆盖全文件夹。","; folder coverage is partial."))):null,
2302
+ result?.searchSource==="api"?h("p",{className:"dshe-hint"},dt("AgentMail 搜索由服务按相关性返回,垃圾箱等特殊邮件不在其搜索范围。","AgentMail ranks results by relevance and excludes spam, trash and other protected mail.")):null,
2303
+ h("div",{className:"dshe-mail-grid"},[
2304
+ h("div",{className:"dshe-mail-list","aria-label":dt("邮件列表","Message list")},rows.length?rows.map(m=>h("button",{key:String(m.uid),type:"button",className:"dshe-mail-row",disabled:busy,"aria-pressed":selected===m.uid,onClick:()=>setSelected(m.uid)},[h("strong",null,(m.flagged?"★ ":"")+(m.seen?"":"● ")+(m.subject||dt("(无主题)","(No subject)"))),h("small",null,who(m.from)),h("small",null,date(m.date))])):h("p",{className:"dshe-mail-empty"},loading?dt("请稍候…","Please wait…"):query?dt("当前检索范围内没有结果。","No results in the current search window."):dt("当前页没有邮件。","No messages on this page."))),
2305
+ h("article",{className:"dshe-mail-detail","aria-label":dt("邮件详情","Message details")},reading&&selected!==null?h("p",{role:"status"},dt("正在读取正文…","Loading message…")):detail?[
2306
+ h("h3",null,detail.subject||dt("(无主题)","(No subject)")),h("p",{className:"dshe-mail-meta"},dt("发件人:","From: ")+who(detail.from)),h("p",{className:"dshe-mail-meta"},dt("收件人:","To: ")+who(detail.to)),detail.cc?.length?h("p",{className:"dshe-mail-meta"},"Cc: "+who(detail.cc)):null,h("p",{className:"dshe-mail-meta"},date(detail.date)),
2307
+ h("div",{className:"dshe-actions"},[
2308
+ ...[false,true].map(all=>h("button",{key:String(all),type:"button",className:"dshe-btn",disabled:busy,onClick:()=>run(async valid=>{const draft=await mailboxApi("replyDraft",{account,folder,uid:selected,replyAll:all});if(valid())onDraft(draft.id);})},all?dt("回复全部到草稿","Reply all in drafts"):dt("回复到草稿","Reply in drafts"))),
2309
+ h("button",{type:"button",className:"dshe-btn",disabled:busy,onClick:()=>run(async valid=>{await mailboxApi("mark",{account,folder,uid:selected,mark:currentRow?.seen?"unread":"read"});if(valid())setResult(old=>({...old,messages:old.messages.map(m=>m.uid===selected?{...m,seen:!m.seen}:m)}));})},currentRow?.seen?dt("标记未读","Mark unread"):dt("标记已读","Mark read")),
2310
+ h("button",{type:"button",className:"dshe-btn",disabled:busy,onClick:()=>run(async valid=>{await mailboxApi("mark",{account,folder,uid:selected,mark:currentRow?.flagged?"unstar":"star"});if(valid())setResult(old=>({...old,messages:old.messages.map(m=>m.uid===selected?{...m,flagged:!m.flagged}:m)}));})},currentRow?.flagged?dt("取消星标","Unstar"):dt("添加星标","Star")),
2311
+ ]),
2312
+ h("pre",null,detail.text||dt("(空正文)","(Empty body)")),detail.truncated?h("p",{className:"dshe-hint"},dt("正文超过显示上限,已截断。","Body truncated at the display limit.")):null,
2313
+ detail.attachments?.length?h("ul",{className:"dshe-files"},detail.attachments.map((file,index)=>h("li",{key:index},[h("span",null,file.filename+" · "+Math.ceil(file.size/1024)+" KiB"),h("button",{type:"button",className:"dshe-btn",disabled:busy,onClick:()=>run(async valid=>{const blob=await mailboxApi("download",{account,folder,uid:selected,index});if(!valid())return;const url=URL.createObjectURL(blob),link=document.createElement("a");link.href=url;link.download=file.filename;link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);})},dt("下载附件","Download attachment"))]))):null,
2314
+ ]:h("p",{className:"dshe-mail-empty"},dt("选择一封邮件查看正文。","Select a message to read it."))),
2315
+ ]),
2316
+ h("div",{className:"dshe-mail-footer"},[
2317
+ h("button",{type:"button",className:"dshe-btn",disabled:loading||busy||offset===0,onClick:()=>setOffset(n=>Math.max(0,n-20))},dt("上一页","Previous page")),h("span",null,dt("第 ","Page ")+(Math.floor(offset/20)+1)+dt(" 页","")),h("button",{type:"button",className:"dshe-btn",disabled:loading||busy||!hasMore,onClick:()=>setOffset(n=>n+20)},dt("下一页","Next page")),
2318
+ result?.headerScan?.nextScanOffset!==undefined?h("button",{type:"button",className:"dshe-btn",disabled:loading||busy,onClick:()=>{setOffset(0);setScanOffset(result.headerScan.nextScanOffset);}},dt("继续搜索更早邮件","Search older headers")):null,
2319
+ ]),
2320
+ ]);
2321
+ }
2322
+
2231
2323
  const DRAFT_ROUTE = "api/dsh-email/drafts";
2232
2324
  const dt = (zh, en) => UI_LANG === "en" ? en : zh;
2233
2325
  // Session memory survives closing the host settings dialog. A page reload
@@ -2244,15 +2336,17 @@ async function draftApi(action, payload, upload) {
2244
2336
  return result.value;
2245
2337
  }
2246
2338
  const draftForm = (draft) => ({ account: draft.account || "", to: draft.to || "", cc: draft.cc || "", subject: draft.subject || "", text: draft.text || "", attachments: draft.attachments.map(file => file.id) });
2247
- const draftState = (state) => ({ draft: dt("未发送", "Not sent"), sending: dt("正在提交 / 待核对", "Submitting / check mailbox"), sent: dt("SMTP 已返回回执", "SMTP receipt saved"), uncertain: dt("结果未确定,请核对邮箱", "Outcome uncertain, check mailbox") })[state] || state;
2339
+ const draftState = (state) => ({ draft: dt("未发送", "Not sent"), sending: dt("正在提交 / 待核对", "Submitting / check mailbox"), sent: dt("已保存服务端回执", "Server receipt saved"), uncertain: dt("结果未确定,请核对邮箱", "Outcome uncertain, check mailbox") })[state] || state;
2248
2340
 
2249
- function DraftWorkbench({ active }) {
2341
+ function DraftWorkbench({ active, requestedDraft }) {
2250
2342
  const [data, setData] = useState({ drafts: [], accounts: [] }), [selected, setSelected] = useState(() => draftUiCache && draftUiCache.selected), [form, setForm] = useState(() => draftUiCache && draftUiCache.form);
2251
2343
  const [busy, setBusy] = useState(false), [error, setError] = useState(""), [notice, setNotice] = useState(""), [preview, setPreview] = useState(null), [acknowledged, setAcknowledged] = useState(false), [pending, setPending] = useState(null), [remove, setRemove] = useState(false), [copy, setCopy] = useState(false);
2252
- const operation = useRef(false);
2344
+ const operation = useRef(false), handledRequested = useRef("");
2253
2345
  const dirty = selected && form && JSON.stringify(form) !== JSON.stringify(draftForm(selected));
2254
2346
  useEffect(() => { draftUiCache = { selected, form }; }, [selected, form]);
2255
2347
  const locked = !selected || selected.state !== "draft";
2348
+ const attachmentCap = data.attachmentLimits?.[form?.account] ?? 20 * 1024 * 1024;
2349
+ const capLabel = attachmentCap >= 1024 * 1024 ? (attachmentCap / 1024 / 1024) + " MiB" : Math.ceil(attachmentCap / 1024) + " KiB";
2256
2350
  const reload = async () => { const next = await draftApi("list"); setData(next); return next; };
2257
2351
  const run = async (fn) => {
2258
2352
  if (operation.current) return;
@@ -2272,9 +2366,10 @@ function DraftWorkbench({ active }) {
2272
2366
  await reload(); setPending(null);
2273
2367
  };
2274
2368
  const requestSwitch = (target) => { if (dirty) setPending(target); else run(() => switchTo(target)); };
2369
+ useEffect(() => { if(active && requestedDraft && handledRequested.current!==requestedDraft && !operation.current && !busy) {handledRequested.current=requestedDraft;requestSwitch({kind:"open",id:requestedDraft});} }, [active,requestedDraft,busy]);
2275
2370
  const field = (key, zh, en, multiline) => h("div", { className: "dshe-field", key }, [
2276
2371
  h("label", { htmlFor: "dshe-draft-" + key }, dt(zh, en)),
2277
- h(multiline ? "textarea" : "input", { id: "dshe-draft-" + key, type: multiline ? undefined : "text", value: form[key], disabled: busy || locked, rows: multiline ? 8 : undefined, maxLength: multiline ? 102400 : key === "subject" ? 500 : 4096, onChange: (event) => { const value = event.target.value; setForm(current => ({ ...current, [key]: value })); setPreview(null); setAcknowledged(false); setNotice(""); } }),
2372
+ h(multiline ? "textarea" : "input", { id: "dshe-draft-" + key, type: multiline ? undefined : "text", value: form[key], disabled: busy || locked || !!selected?.reply && key === "subject", rows: multiline ? 8 : undefined, maxLength: multiline ? 102400 : key === "subject" ? 500 : 4096, onChange: (event) => { const value = event.target.value; setForm(current => ({ ...current, [key]: value })); setPreview(null); setAcknowledged(false); setNotice(""); } }),
2278
2373
  ]);
2279
2374
  const review = preview && h("section", { className: "dshe-review", "aria-label": dt("发送前预览", "Review before sending") }, [
2280
2375
  h("h3", null, dt("发送前预览", "Review before sending")),
@@ -2314,16 +2409,16 @@ function DraftWorkbench({ active }) {
2314
2409
  h("aside", { className: "dshe-draft-list", "aria-label": dt("已保存的草稿", "Saved drafts") }, data.drafts.length ? data.drafts.map(draft => h("button", { key: draft.id, type: "button", className: "dshe-draft-row", disabled: busy, "aria-pressed": selected && selected.id === draft.id, onClick: () => requestSwitch({ kind: "open", id: draft.id }) }, [h("strong", null, draft.subject || dt("未命名草稿", "Untitled draft")), h("small", null, draft.account + " · " + draftState(draft.state))])) : h("p", { className: "dshe-hint" }, dt("还没有草稿,先新建一份。", "No drafts yet. Create your first draft."))),
2315
2410
  selected && form ? h("div", { className: "dshe-draft-editor" }, [
2316
2411
  h("div", { role: "status", className: "dshe-hint" }, dirty ? dt("有未保存的编辑", "Unsaved edits") : draftState(selected.state)),
2317
- h("div", { className: "dshe-field" }, [h("label", { htmlFor: "dshe-draft-account" }, dt("发件账号", "Sending account")), h("select", { id: "dshe-draft-account", value: form.account, disabled: busy || locked, onChange: event => { const value = event.target.value; setForm(current => ({ ...current, account: value })); setPreview(null); setAcknowledged(false); } }, [h("option", { value: "" }, dt("请选择账号", "Choose an account")), ...[...new Set([...data.accounts, ...(form.account ? [form.account] : [])])].map(name => h("option", { key: name, value: name }, name))])]),
2412
+ h("div", { className: "dshe-field" }, [h("label", { htmlFor: "dshe-draft-account" }, dt("发件账号", "Sending account")), h("select", { id: "dshe-draft-account", value: form.account, disabled: busy || locked || !!selected?.reply, onChange: event => { const value = event.target.value; setForm(current => ({ ...current, account: value })); setPreview(null); setAcknowledged(false); } }, [h("option", { value: "" }, dt("请选择账号", "Choose an account")), ...[...new Set([...data.accounts, ...(form.account ? [form.account] : [])])].map(name => h("option", { key: name, value: name }, name))])]),
2318
2413
  field("to", "收件人 To", "Recipients To"), field("cc", "抄送 Cc", "Copy Cc"), field("subject", "主题", "Subject"), field("text", "正文", "Message", true),
2319
2414
  h("ul", { className: "dshe-files" }, selected.attachments.filter(file => form.attachments.includes(file.id)).map(file => h("li", { key: file.id }, [h("span", null, file.filename), !locked ? h("button", { type: "button", className: "dshe-btn", disabled: busy, "aria-label": dt("移除附件 ", "Remove attachment ") + file.filename, onClick: () => { setForm(current => ({ ...current, attachments: current.attachments.filter(id => id !== file.id) })); setPreview(null); setAcknowledged(false); } }, dt("移除", "Remove")) : null]))),
2320
- !locked ? h("label", { className: "dshe-upload" }, [dt("添加附件(先保存编辑,最多 10 个 / 合计 20 MiB)", "Add attachments (save edits first; up to 10 files / 20 MiB total)"), h("input", { type: "file", multiple: true, disabled: busy || dirty, "aria-label": dt("添加附件", "Add attachments"), onChange: event => { const files = Array.from(event.target.files || []); event.target.value = ""; run(async () => { let current = selected; for (const file of files) { if (file.size > 20 * 1024 * 1024) throw new Error(dt("单个附件超过 20 MiB", "Attachment exceeds 20 MiB")); current = await draftApi("upload", { id: current.id, revision: current.revision }, file); adopt(current); } await reload(); setNotice(dt("附件已添加,尚未发送。请检查预览。", "Attachments added. Review before sending.")); }); } })]) : null,
2415
+ !locked ? h("label", { className: "dshe-upload" }, [dt("添加附件(先保存编辑,最多 10 个 / 合计 ", "Add attachments (save edits first; up to 10 files / ") + capLabel + dt(")", " total)"), h("input", { type: "file", multiple: true, disabled: busy || dirty, "aria-label": dt("添加附件", "Add attachments"), onChange: event => { const files = Array.from(event.target.files || []); event.target.value = ""; run(async () => { let current = selected; for (const file of files) { if (file.size > attachmentCap) throw new Error(dt("单个附件超过 ", "Attachment exceeds ") + capLabel); current = await draftApi("upload", { id: current.id, revision: current.revision }, file); adopt(current); } await reload(); setNotice(dt("附件已添加,尚未发送。请检查预览。", "Attachments added. Review before sending.")); }); } })]) : null,
2321
2416
  !locked ? h("div", { className: "dshe-actions" }, [
2322
2417
  h("button", { type: "button", className: "dshe-btn", disabled: busy || !dirty, onClick: () => run(save) }, dt("保存草稿", "Save draft")),
2323
2418
  h("button", { type: "button", className: "dshe-btn primary", disabled: busy || dirty, onClick: () => run(async () => { setPreview(await draftApi("preview", { id: selected.id, revision: selected.revision })); setAcknowledged(false); setNotice(""); }) }, dt("检查发送预览", "Review before sending")),
2324
2419
  ]) : null,
2325
2420
  review,
2326
- selected.receipt ? h("div", { className: "dshe-alert " + (selected.receipt.rejected.length ? "info" : "success"), role: "status" }, [(selected.receipt.rejected.length ? dt("部分地址被服务器拒绝,邮件不会自动重发。服务器接受:", "Some addresses were rejected. This email will not retry automatically. Server accepted: ") : dt("SMTP 已返回回执,邮件不会再次发送。服务器接受:", "SMTP receipt saved. This draft will not send again. Server accepted: ")) + (selected.receipt.accepted.join(", ") || dt("无", "none")), h("p", null, dt("服务器拒绝:", "Server rejected: ") + (selected.receipt.rejected.join(", ") || dt("无", "none"))), h("p", null, dt("这是服务器提交结果,不代表已送达收件人的收件箱。", "This confirms the server response, not delivery to the recipient inbox."))]) : null,
2421
+ selected.receipt ? h("div", { className: "dshe-alert " + (selected.receipt.rejected.length ? "info" : "success"), role: "status" }, [(selected.receipt.rejected.length ? dt("部分地址被服务器拒绝,邮件不会自动重发。服务器接受:", "Some addresses were rejected. This email will not retry automatically. Server accepted: ") : dt("服务器已返回回执,邮件不会再次发送。服务器接受:", "Server receipt saved. This draft will not send again. Server accepted: ")) + (selected.receipt.accepted.join(", ") || dt("无", "none")), h("p", null, dt("服务器拒绝:", "Server rejected: ") + (selected.receipt.rejected.join(", ") || dt("无", "none"))), h("p", null, dt("这是服务器提交结果,不代表已送达收件人的收件箱。", "This confirms the server response, not delivery to the recipient inbox."))]) : null,
2327
2422
  selected.state === "uncertain" || selected.state === "sending" ? h("div", { className: "dshe-alert info", role: "status" }, selected.error || dt("提交尚未留下回执。先核对邮箱,再刷新列表;不要重复发送。", "No receipt yet. Check your mailbox, then refresh the list. Do not send again.")) : null,
2328
2423
  h("div", { className: "dshe-local-actions" }, [
2329
2424
  h("button", { type: "button", className: "dshe-btn", disabled: busy || selected.state === "sending" || dirty, onClick: () => setCopy(true) }, dt("复制为新草稿", "Copy to a new draft")),
@@ -2374,6 +2469,8 @@ function RecipientRules({ active, onSaved }) {
2374
2469
 
2375
2470
  function EmailSettingsSection() {
2376
2471
  const [tab, setTab] = useState(emailActiveTab);
2472
+ const [requestedDraft,setRequestedDraft]=useState("");
2473
+ const openMailboxDraft=id=>{setRequestedDraft(id);setTab("drafts");};
2377
2474
  useEffect(() => { emailActiveTab = tab; }, [tab]);
2378
2475
  const [draft, setDraft] = useState(null);
2379
2476
  useEffect(() => {
@@ -2661,13 +2758,14 @@ function EmailSettingsSection() {
2661
2758
 
2662
2759
  return h("div", { className: "dshe-settings" }, [
2663
2760
  h("header", { className: "dshe-header" }, [
2664
- h("span", { className: "dshe-kicker" }, "dsh-email · IMAP/SMTP"),
2761
+ h("span", { className: "dshe-kicker" }, "dsh-email · IMAP / SMTP / AgentMail"),
2665
2762
  h("h2", null, t("section.title")),
2666
- h("p", null, dt("配置邮箱账号,编辑草稿并确认发送,或管理收件人规则。账号设置修改后会自动保存。", "Configure accounts, edit and confirm drafts, or manage recipient rules. Account settings save automatically.")),
2763
+ h("p", null, dt("浏览和搜索邮件,把回复存成草稿,核对后发送。账号设置修改后会自动保存。", "Browse and search mail, prepare replies in drafts, and review before sending. Account settings save automatically.")),
2667
2764
  ]),
2668
2765
  tab === "accounts" ? status : null,
2669
- h("div", { className: "dshe-tabs", role: "tablist", "aria-label": dt("邮件功能", "Email features") }, [["accounts", dt("邮箱账号", "Accounts")], ["drafts", dt("草稿", "Drafts")], ["rules", dt("收件人规则", "Recipient rules")]].map(([id, label]) => h("button", { key: id, type: "button", role: "tab", className: "dshe-btn", "aria-selected": tab === id, "aria-controls": "dshe-pane-" + id, onClick: () => setTab(id) }, label))),
2670
- h("div", { id: "dshe-pane-drafts", role: "tabpanel", hidden: tab !== "drafts", className: "dshe-section-hidden" }, h(DraftWorkbench, { active: tab === "drafts" })),
2766
+ h("div", { className: "dshe-tabs", role: "tablist", "aria-label": dt("邮件功能", "Email features") }, [["accounts", dt("邮箱账号", "Accounts")], ["mailbox", dt("收发邮件", "Mailbox")], ["drafts", dt("草稿", "Drafts")], ["rules", dt("收件人规则", "Recipient rules")]].map(([id, label]) => h("button", { key: id, type: "button", role: "tab", className: "dshe-btn", "aria-selected": tab === id, "aria-controls": "dshe-pane-" + id, onClick: () => setTab(id) }, label))),
2767
+ h("div", {id:"dshe-pane-mailbox",role:"tabpanel",hidden:tab!=="mailbox",className:"dshe-section-hidden"},h(MailboxWorkbench,{active:tab==="mailbox",onConfigure:()=>setTab("accounts"),onDraft:openMailboxDraft})),
2768
+ h("div", { id: "dshe-pane-drafts", role: "tabpanel", hidden: tab !== "drafts", className: "dshe-section-hidden" }, h(DraftWorkbench, { active: tab === "drafts", requestedDraft })),
2671
2769
  h("div", { id: "dshe-pane-rules", role: "tabpanel", hidden: tab !== "rules", className: "dshe-section-hidden" }, h(RecipientRules, { active: tab === "rules", onSaved: revision => { setSnapshot(old => ({ ...old, settings: { ...old.settings, revision } })); } })),
2672
2770
  h("section", { id: "dshe-pane-accounts", role: "tabpanel", hidden: tab !== "accounts", className: "dshe-panel dshe-section-hidden" }, [
2673
2771
  // 全局设置:发信确认和下载目录对所有账号一视同仁,不属于任何一张卡片,
package/lib/config.d.ts CHANGED
@@ -71,6 +71,8 @@ export declare const ENDPOINT_DEFAULTS: {
71
71
  export declare function normalizeSettingsEndpoint<T extends SmtpConfig>(value: T, kind: keyof typeof ENDPOINT_DEFAULTS): Omit<T, 'host' | 'port' | 'secure'> & SmtpConfig;
72
72
  /** One mailbox account. Top-level shorthand fields act as shared defaults. */
73
73
  export interface AccountConfig {
74
+ /** AgentMail credential; the account editor also accepts it through its secret field. */
75
+ apiKey?: string;
74
76
  /** Built-in provider name, or a custom serverPresets name. */
75
77
  provider?: ProviderRef;
76
78
  user?: string;
@@ -175,6 +177,8 @@ export declare const PROVIDER_NAMES: string[];
175
177
  export declare const EMAIL_PASSWORD_ENV = "DSH_EMAIL_PASSWORD";
176
178
  /** Fully resolved, validated configuration for one account. */
177
179
  export interface ResolvedEmailConfig {
180
+ transport?: 'agentmail';
181
+ apiBase?: 'https://api.agentmail.to' | 'https://api.agentmail.eu';
178
182
  user: string;
179
183
  /** Display name for the From header, '' when the account does not set one. */
180
184
  senderName: string;
package/lib/config.js CHANGED
@@ -228,7 +228,7 @@ export function resolveEmailSettings(config, onlyAccount) {
228
228
  const common = {
229
229
  provider: raw.provider,
230
230
  user: raw.user,
231
- password: raw.password,
231
+ password: raw.password, apiKey: raw.apiKey,
232
232
  senderName: raw.senderName,
233
233
  authUser: raw.authUser,
234
234
  authPassword: raw.authPassword,
@@ -327,7 +327,7 @@ function providerTable(custom) {
327
327
  }
328
328
  /** Every name a `provider:` may legally use, built-ins first. */
329
329
  export function providerNames(custom = {}) {
330
- const names = [...PROVIDER_NAMES];
330
+ const names = [...PROVIDER_NAMES, 'agentmail', 'agentmail-eu'];
331
331
  for (const name of Object.keys(custom))
332
332
  if (!names.includes(name))
333
333
  names.push(name);
@@ -369,6 +369,16 @@ function resolveAccount(name, common, acc, allowEnvPassword, providers, known) {
369
369
  // Normalize it like the legacy projection and YAML writer before preset lookup.
370
370
  const requested = (acc.provider === '' ? undefined : acc.provider) ?? (common.provider === '' ? undefined : common.provider);
371
371
  const preset = requested === undefined ? undefined : providers[requested];
372
+ if (requested === 'agentmail' || requested === 'agentmail-eu') {
373
+ const user = String(acc.user ?? common.user ?? '').trim();
374
+ const sharedApi = common.provider === requested;
375
+ const key = String(acc.password || acc.apiKey || (sharedApi ? common.password || common.apiKey : '') || (allowEnvPassword ? process.env.DSH_AGENTMAIL_API_KEY ?? '' : '')).trim();
376
+ if (!/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(user) || !key || key.length > 4096 || /[^\x21-\x7e]/.test(key))
377
+ throw new Error('AgentMail 账号需要有效邮箱地址和 API Key,请在「设置 → 邮件」补全后测试连接。');
378
+ return { transport: 'agentmail', apiBase: requested === 'agentmail-eu' ? 'https://api.agentmail.eu' : 'https://api.agentmail.to', user,
379
+ senderName: '', authUser: user, authPassword: key, password: key, authKind: 'password',
380
+ imap: { host: '', port: 993, secure: true }, smtp: { host: '', port: 465, secure: true }, inboxFolder: 'INBOX' };
381
+ }
372
382
  if (requested !== undefined && preset === undefined) {
373
383
  throw new Error(`dsh-email:账号 "${name}" 的 provider "${requested}" 未知,可选:${known.join('/')};或省略 provider 直接填 imap.host 与 smtp.host`);
374
384
  }
@@ -21,6 +21,11 @@ export interface EmailDraft {
21
21
  attemptAt?: string;
22
22
  receipt?: EmailSendResult;
23
23
  error?: string;
24
+ reply?: {
25
+ uid: number | string;
26
+ folder: string;
27
+ uidValidity?: number;
28
+ };
24
29
  }
25
30
  export interface DraftFields {
26
31
  account: string;
@@ -43,6 +43,10 @@ export class DraftStore {
43
43
  if (!isId(draft.id) || !Number.isSafeInteger(draft.revision) || draft.revision < 1 || !['draft', 'sending', 'sent', 'uncertain'].includes(draft.state))
44
44
  throw new Error('invalid draft');
45
45
  draftFields(draft);
46
+ if (draft.reply && (!(typeof draft.reply.uid === 'number' && Number.isSafeInteger(draft.reply.uid) && draft.reply.uid > 0 || typeof draft.reply.uid === 'string' && draft.reply.uid.length > 0 && draft.reply.uid.length <= 998 && !/[\r\n\0]/.test(draft.reply.uid)) || typeof draft.reply.folder !== 'string' || draft.reply.folder.length > 1024))
47
+ throw new Error('invalid reply reference');
48
+ if (draft.reply?.uidValidity !== undefined && (!Number.isSafeInteger(draft.reply.uidValidity) || draft.reply.uidValidity <= 0))
49
+ throw new Error('invalid reply UIDVALIDITY');
46
50
  if (!Array.isArray(draft.attachments) || draft.attachments.length > 10 || draft.attachments.some(file => !isId(file.id) || !isAbsolute(file.path) || typeof file.filename !== 'string' || typeof file.owned !== 'boolean'))
47
51
  throw new Error('invalid attachments');
48
52
  }
package/lib/draft-web.js CHANGED
@@ -37,7 +37,7 @@ export class EmailDraftBackend {
37
37
  if (!config)
38
38
  throw new DraftError('发件账号不存在,请重新选择账号');
39
39
  const value = this.runtime.getSettingsValue(), policies = parseRecipientPolicies(value.trustedRecipientsYaml);
40
- return { name, config, cap: Math.min(settings.maxAttachmentBytes, MAX_BYTES), policy: policies.get(name), key: hash({ name, config, cap: settings.maxAttachmentBytes, policies: value.trustedRecipientsYaml ?? '', approval: value.sendApproval }) };
40
+ return { name, config, cap: Math.min(settings.maxAttachmentBytes, config.transport === 'agentmail' ? 4 * 1024 * 1024 : MAX_BYTES), policy: policies.get(name), key: hash({ name, config, cap: settings.maxAttachmentBytes, policies: value.trustedRecipientsYaml ?? '', approval: value.sendApproval }) };
41
41
  }
42
42
  safe(error) {
43
43
  let message = redactCredentials(messageOf(error, '操作失败,请稍后重试'));
@@ -127,7 +127,13 @@ export class EmailDraftBackend {
127
127
  if (body.action === 'list') {
128
128
  await this.recover(signal);
129
129
  const snapshot = await this.settings.snapshot();
130
- return { drafts: (await this.store.list()).map(view), accounts: snapshot.accounts, revision: snapshot.settings.revision, writable: snapshot.writable };
130
+ const attachmentLimits = {};
131
+ try {
132
+ for (const name of this.runtime.getEffectiveSettings().accounts.keys())
133
+ attachmentLimits[name] = this.connection(name).cap;
134
+ }
135
+ catch { /* Drafts remain usable before account setup. */ }
136
+ return { drafts: (await this.store.list()).map(view), accounts: snapshot.accounts, attachmentLimits, revision: snapshot.settings.revision, writable: snapshot.writable };
131
137
  }
132
138
  if (body.action === 'rules') {
133
139
  const snapshot = await this.settings.snapshot();
@@ -162,6 +168,8 @@ export class EmailDraftBackend {
162
168
  if (body.action === 'update') {
163
169
  editable(draft);
164
170
  const fields = draftFields(body), keep = body.attachments ?? draft.attachments.map(file => file.id);
171
+ if (draft.reply && (fields.account !== draft.account || fields.subject !== draft.subject))
172
+ throw new DraftError('回复草稿须保留原发件账号和主题;如需更换请新建邮件');
165
173
  if (!Array.isArray(keep) || keep.some(key => !draft.attachments.some(file => file.id === key)) || new Set(keep).size !== keep.length)
166
174
  throw new DraftError('附件列表无效,请重新打开草稿');
167
175
  const changed = await this.store.change(id, body.revision, value => { editable(value); Object.assign(value, fields); value.attachments = value.attachments.filter(file => keep.includes(file.id)); }, signal);
@@ -192,7 +200,8 @@ export class EmailDraftBackend {
192
200
  files.push(stored);
193
201
  await this.writeUpload(owned.path, file.content, signal);
194
202
  }
195
- const changed = await this.store.change(created.id, created.revision, value => { value.attachments = files; }, signal);
203
+ const changed = await this.store.change(created.id, created.revision, value => { value.attachments = files; if (draft.reply)
204
+ value.reply = { ...draft.reply }; }, signal);
196
205
  return view(changed);
197
206
  }
198
207
  catch (error) {
@@ -245,7 +254,7 @@ export class EmailDraftBackend {
245
254
  }
246
255
  let receipt;
247
256
  try {
248
- receipt = await pool.sendPrepared(before.account, before.to, before.subject, before.text, before.cc, plan.attachments.map(({ filename, content }) => ({ filename, content })), current);
257
+ receipt = await pool.sendPrepared(before.account, before.to, before.subject, before.text, before.cc, plan.attachments.map(({ filename, content }) => ({ filename, content })), current, before.reply);
249
258
  }
250
259
  catch (error) {
251
260
  if (error instanceof RecipientPolicyError) {
@@ -257,12 +266,12 @@ export class EmailDraftBackend {
257
266
  throw new DraftError('提交结果未确定,且回执未能保存;请先去邮箱核对,不要直接重发', 'draft-storage');
258
267
  return { draft: view(failed) };
259
268
  }
260
- // Once SMTP acknowledges, a disconnected browser must not prevent the receipt from being persisted.
269
+ // Once the server acknowledges, persist its receipt even if the browser disconnects.
261
270
  try {
262
271
  return { draft: view(await this.store.change(id, pending.revision, value => { value.state = 'sent'; value.receipt = receipt; })) };
263
272
  }
264
273
  catch {
265
- throw new DraftError('SMTP 已返回回执,但本地保存失败;请先核对邮箱,不要再次发送', 'draft-storage');
274
+ throw new DraftError('服务器已返回回执,但本地保存失败;请先核对邮箱,不要再次发送', 'draft-storage');
266
275
  }
267
276
  }, signal);
268
277
  }
@@ -7,6 +7,7 @@ const endpoint = () => z.object({
7
7
  export const Config = z.object({
8
8
  provider: z.string().volatile(), user: z.string().volatile(),
9
9
  password: z.string().role('secret').volatile(),
10
+ apiKey: z.string().role('secret').volatile(),
10
11
  senderName: z.string().volatile(), authUser: z.string().volatile(),
11
12
  authPassword: z.string().role('secret').volatile(),
12
13
  clientId: z.string().volatile(), authKind: z.union(['password', 'oauth2']).volatile(),
package/lib/index.d.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  import type { EmailConfig } from './config.js';
2
+ import { type MailDependencies } from './mail-service.js';
2
3
  export declare const Config: import("@deepseek-ai/schemastery").default;
3
4
  export declare const name = "tool-email";
4
5
  export declare const inject: string[];
5
6
  export type Config = EmailConfig;
6
7
  /** Compose settings/pool lifecycle, tools, browser routes and the outgoing-mail gate. */
7
8
  export declare function apply(ctx: any, config?: Config): void;
9
+ /** Shared application assembly; transports can be supplied by embedders and protocol fixtures. */
10
+ export declare function installEmailApplication(ctx: any, config?: Config, dependencies?: MailDependencies): void;
8
11
  export { clampInt, defaultDownloadDir, EMAIL_PASSWORD_ENV, isOAuth2Account, OUTLOOK_OAUTH2_CLIENT_ID, OUTLOOK_PROVIDER, parseAccountsYaml, parseServerPresets, presetNamesIn, providerNames, PROVIDER_NAMES, resolveEmailConfig, resolveEmailSettings, serializeAccountsYaml } from './config.js';
9
12
  export type { AuthKind, ResolvedEmailConfig } from './config.js';
10
13
  export { ACCESS_TOKEN_MARGIN_MS, classifyOAuthFailure, clearTokenFor, clientIdOf, DEVICE_CODE_URL, getFreshAccessToken, mapAadstsMessage, NO_CLIENT_ID_MESSAGE, NOT_LOGGED_IN_MESSAGE, oauth2StateOf, oauth2TokenFile, OAUTH2_SCOPES, OAuth2Error, pollDeviceFlow, readTokenStore, startDeviceFlow, TOKEN_URL, writeTokenStore, } from './oauth2.js';
@@ -17,3 +20,6 @@ export { EmailSettingsBackend, installEmailSettingsWeb, SETTINGS_ROUTE } from '.
17
20
  export type { AccountCardData, AccountCardInput } from './web.js';
18
21
  export { EmailDraftBackend, installEmailDrafts, EMAIL_DRAFT_ROUTE } from './draft-web.js';
19
22
  export { DraftStore, DraftError } from './draft-store.js';
23
+ export { AgentMailClient } from './agentmail.js';
24
+ export { EmailService } from './mail-service.js';
25
+ export { EmailMailboxBackend, installEmailMailbox, MAILBOX_ROUTE } from './mailbox-web.js';
package/lib/index.js CHANGED
@@ -6,11 +6,17 @@ import { liveConfig, Config as ConfigSchema } from './host-config.js';
6
6
  import { installLegacySettingsImport } from './legacy-settings.js';
7
7
  import { EmailSettingsSchema, SETTINGS_NAMESPACE } from './settings.js';
8
8
  import { EmailDraftBackend, installEmailDrafts } from './draft-web.js';
9
+ import { EmailMailboxBackend, installEmailMailbox } from './mailbox-web.js';
10
+ import { EmailService } from './mail-service.js';
9
11
  export const Config = ConfigSchema;
10
12
  export const name = 'tool-email';
11
13
  export const inject = ['settings', 'tools'];
12
14
  /** Compose settings/pool lifecycle, tools, browser routes and the outgoing-mail gate. */
13
15
  export function apply(ctx, config = {}) {
16
+ installEmailApplication(ctx, config);
17
+ }
18
+ /** Shared application assembly; transports can be supplied by embedders and protocol fixtures. */
19
+ export function installEmailApplication(ctx, config = {}, dependencies = {}) {
14
20
  config = liveConfig(config);
15
21
  installLegacySettingsImport(ctx, config, SETTINGS_NAMESPACE, 'tool-email', Object.keys(EmailSettingsSchema.dict));
16
22
  if (typeof ctx.settings.configure === 'function' && typeof ctx.inject === 'function') {
@@ -18,12 +24,13 @@ export function apply(ctx, config = {}) {
18
24
  settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber));
19
25
  });
20
26
  }
21
- const runtime = createEmailRuntime(ctx, config);
22
- const backend = new EmailSettingsBackend(ctx, runtime.settingsScope, config);
27
+ const runtime = createEmailRuntime(ctx, config, settings => new EmailService(settings, dependencies));
28
+ const backend = new EmailSettingsBackend(ctx, runtime.settingsScope, config, dependencies.fetch);
23
29
  backend.watchImpl = runtime.watch;
24
30
  installEmailSettingsWeb(ctx, backend);
25
31
  const drafts = new EmailDraftBackend(runtime, backend);
26
32
  installEmailDrafts(ctx, drafts);
33
+ installEmailMailbox(ctx, new EmailMailboxBackend(runtime, drafts));
27
34
  for (const definition of buildEmailTools(runtime, drafts))
28
35
  ctx.tools.register(definition);
29
36
  installSendApproval(ctx, runtime);
@@ -37,3 +44,6 @@ export { parseEmailDay } from './tool-contract.js';
37
44
  export { EmailSettingsBackend, installEmailSettingsWeb, SETTINGS_ROUTE } from './web.js';
38
45
  export { EmailDraftBackend, installEmailDrafts, EMAIL_DRAFT_ROUTE } from './draft-web.js';
39
46
  export { DraftStore, DraftError } from './draft-store.js';
47
+ export { AgentMailClient } from './agentmail.js';
48
+ export { EmailService } from './mail-service.js';
49
+ export { EmailMailboxBackend, installEmailMailbox, MAILBOX_ROUTE } from './mailbox-web.js';