dsh-email 0.14.7 → 0.15.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 +96 -45
- package/README.en.md +25 -243
- package/README.md +25 -255
- package/docs/CHANGELOG.en.md +78 -0
- package/docs/DRAFTS.en.md +54 -0
- package/docs/DRAFTS.md +54 -0
- package/docs/USAGE.en.md +219 -0
- package/docs/USAGE.md +225 -0
- package/docs/VALIDATION.md +25 -0
- package/docs/validation/0.15.0.md +29 -0
- package/lib/approval.js +14 -1
- package/lib/client.js +154 -5
- package/lib/config.d.ts +2 -0
- package/lib/draft-store.d.ts +57 -0
- package/lib/draft-store.js +169 -0
- package/lib/draft-web.d.ts +32 -0
- package/lib/draft-web.js +386 -0
- package/lib/host-config.js +1 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +6 -1
- package/lib/mail-client.d.ts +5 -0
- package/lib/mail-client.js +8 -0
- package/lib/recipient-policy.d.ts +26 -0
- package/lib/recipient-policy.js +88 -0
- package/lib/runtime.d.ts +1 -1
- package/lib/settings.d.ts +3 -0
- package/lib/settings.js +10 -0
- package/lib/tools.d.ts +2 -1
- package/lib/tools.js +9 -1
- package/lib/web.d.ts +24 -0
- package/lib/web.js +15 -0
- package/package.json +8 -5
package/lib/client.js
CHANGED
|
@@ -148,6 +148,10 @@ const CSS = [
|
|
|
148
148
|
".dshe-header p{max-width:640px;margin:4px 0 0;color:var(--dsw-alias-label-tertiary,#77736d);font-size:13px;line-height:1.55}",
|
|
149
149
|
".dshe-kicker{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--dsw-alias-state-business-primary,#0b6c9f);font-weight:700}",
|
|
150
150
|
".dshe-panel{display:grid;gap:12px;padding:15px;border:1px solid var(--dsw-alias-border-l2,#dedbd5);border-radius:14px;background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 1px 1px rgba(0,0,0,.02)}",
|
|
151
|
+
".dshe-panel{container-type:inline-size}@container(max-width:650px){.dshe-panel .dshe-workbench{grid-template-columns:minmax(0,1fr)}.dshe-panel .dshe-draft-list{max-height:160px;grid-template-columns:repeat(auto-fit,minmax(min(100%,180px),1fr))}}",
|
|
152
|
+
"@media(max-width:700px){[role=dialog]:has(.dshe-settings){width:100%!important;max-width:100%!important;flex-direction:column!important}[role=dialog]:has(.dshe-settings)>nav+div{min-width:0;width:100%}[role=dialog]:has(.dshe-settings)>nav+div>div:has(.dshe-settings){padding:12px!important;min-width:0}[role=dialog]:has(.dshe-settings) nav{width:100%!important;flex-basis:auto!important;border-right:0;max-height:170px;overflow:auto}[role=dialog]:has(.dshe-settings) nav>div:has(>button){display:flex;flex-direction:row!important;flex-wrap:wrap;flex-shrink:0;gap:4px}}",
|
|
153
|
+
".dshe-tabs{display:flex;gap:6px;flex-wrap:wrap}.dshe-tabs [aria-selected=true]{background:var(--dsw-alias-button-primary-fill,#0b6c9f);color:var(--dsw-alias-label-primary-foreground,#fff)}",
|
|
154
|
+
".dshe-workbench{display:grid;grid-template-columns:minmax(150px,210px) minmax(0,1fr);gap:14px}.dshe-workbench>*{min-width:0}.dshe-draft-list{display:grid;gap:6px;max-height:340px;overflow:auto;align-content:start}.dshe-draft-row{display:grid;gap:4px;text-align:left;padding:10px;border:1px solid var(--dsw-alias-border-l2,#dedbd5);border-radius:9px;background:transparent;color:inherit;cursor:pointer;overflow-wrap:anywhere}.dshe-draft-row[aria-pressed=true]{border-color:var(--dsw-alias-state-business-primary,#0b6c9f)}.dshe-draft-row small{font-size:11px;opacity:.75}.dshe-draft-editor{display:grid;gap:12px;min-width:0}.dshe-review{display:grid;gap:10px;padding:12px;border:1px solid var(--dsw-alias-state-business-primary,#0b6c9f);border-radius:12px}.dshe-review h3{margin:0;font-size:16px}.dshe-review p{margin:0;overflow-wrap:anywhere;font-size:13px}.dshe-review pre{margin:0;padding:10px;background:var(--dsw-specific-input-major,#fafafa);border-radius:8px;white-space:pre-wrap;overflow-wrap:anywhere;max-height:240px;overflow:auto;font:inherit;font-size:13px}.dshe-files{display:grid;gap:7px;margin:0;padding:0;list-style:none}.dshe-files li{display:flex;align-items:center;justify-content:space-between;gap:8px;overflow-wrap:anywhere;font-size:13px}.dshe-files li span{min-width:0}.dshe-match-list{margin:0;padding-left:18px;font-size:12px;line-height:1.8;overflow-wrap:anywhere}.dshe-review .dshe-check{align-items:flex-start}.dshe-draft-editor textarea{resize:vertical;min-height:160px}.dshe-rule-panel textarea{resize:vertical;min-height:100px}.dshe-section-hidden[hidden]{display:none}.dshe-upload{display:grid;gap:6px;font-size:12px;min-width:0}.dshe-upload input{max-width:100%;font:inherit}.dshe-local-actions{display:flex;gap:6px;flex-wrap:wrap}.dshe-workbench h3{font-size:15px;margin:0}.dshe-workbench .dshe-grid{grid-template-columns:1fr}.dshe-editor-empty{padding:20px 0;color:var(--dsw-alias-label-tertiary,#77736d)}@media(max-width:600px){.dshe-workbench{grid-template-columns:minmax(0,1fr)}.dshe-draft-list{max-height:180px}.dshe-review{padding:10px}.dshe-files li{align-items:flex-start}}",
|
|
151
155
|
".dshe-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}",
|
|
152
156
|
".dshe-field{display:grid;gap:6px}",
|
|
153
157
|
".dshe-field label{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary,#26231f)}",
|
|
@@ -890,13 +894,13 @@ function draftToInput(draft) {
|
|
|
890
894
|
|
|
891
895
|
/**
|
|
892
896
|
* 半填账号:名字、邮箱地址、服务商三者缺一都算。服务商是账号的连接身份 —— 没有它
|
|
893
|
-
*
|
|
897
|
+
* 没有服务商且未填完整的自定义服务器时,就没有端点可展开。
|
|
894
898
|
* OAuth2 账号不要密码:有 (name, provider, user) 就算填完了。
|
|
895
899
|
*/
|
|
896
900
|
function accountIncomplete(draft) {
|
|
897
901
|
return String(draft.name || "").trim() === ""
|
|
898
902
|
|| String(draft.user || "").trim() === ""
|
|
899
|
-
|| String(draft.provider || "").trim() === "";
|
|
903
|
+
|| (String(draft.provider || "").trim() === "" && !(draft.imap?.host && draft.smtp?.host));
|
|
900
904
|
}
|
|
901
905
|
|
|
902
906
|
/** 改过名字后草稿要跟着键走:旧键删掉,整份草稿(含 imap/smtp)搬到新键下。 */
|
|
@@ -2132,7 +2136,149 @@ function ServerPresetsEditor(props) {
|
|
|
2132
2136
|
]);
|
|
2133
2137
|
}
|
|
2134
2138
|
|
|
2139
|
+
const DRAFT_ROUTE = "api/dsh-email/drafts";
|
|
2140
|
+
const dt = (zh, en) => UI_LANG === "en" ? en : zh;
|
|
2141
|
+
// Session memory survives closing the host settings dialog. A page reload
|
|
2142
|
+
// still uses the unsaved-edits guard, and only an explicit Save writes content.
|
|
2143
|
+
let draftUiCache = null, rulesUiCache = null, emailActiveTab = "accounts";
|
|
2144
|
+
async function draftApi(action, payload, upload) {
|
|
2145
|
+
const init = { credentials: "same-origin", method: "POST", headers: { "x-dsh-email-draft": "1", "Content-Type": upload ? "application/octet-stream" : "application/json" } };
|
|
2146
|
+
if (upload) {
|
|
2147
|
+
Object.assign(init.headers, { "x-dsh-draft-id": payload.id, "x-dsh-draft-revision": String(payload.revision), "x-dsh-draft-filename": encodeURIComponent(upload.name) });
|
|
2148
|
+
init.body = upload;
|
|
2149
|
+
} else init.body = JSON.stringify({ ...payload, action });
|
|
2150
|
+
const response = await fetch(upload ? DRAFT_ROUTE + "/upload" : DRAFT_ROUTE, init), result = await response.json();
|
|
2151
|
+
if (!response.ok || !result.ok) { const error = new Error(result.error && result.error.message || dt("邮件操作失败", "Email action failed")); error.code = result.error && result.error.code; throw error; }
|
|
2152
|
+
return result.value;
|
|
2153
|
+
}
|
|
2154
|
+
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) });
|
|
2155
|
+
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;
|
|
2156
|
+
|
|
2157
|
+
function DraftWorkbench({ active }) {
|
|
2158
|
+
const [data, setData] = useState({ drafts: [], accounts: [] }), [selected, setSelected] = useState(() => draftUiCache && draftUiCache.selected), [form, setForm] = useState(() => draftUiCache && draftUiCache.form);
|
|
2159
|
+
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);
|
|
2160
|
+
const operation = useRef(false);
|
|
2161
|
+
const dirty = selected && form && JSON.stringify(form) !== JSON.stringify(draftForm(selected));
|
|
2162
|
+
useEffect(() => { draftUiCache = { selected, form }; }, [selected, form]);
|
|
2163
|
+
const locked = !selected || selected.state !== "draft";
|
|
2164
|
+
const reload = async () => { const next = await draftApi("list"); setData(next); return next; };
|
|
2165
|
+
const run = async (fn) => {
|
|
2166
|
+
if (operation.current) return;
|
|
2167
|
+
operation.current = true; setBusy(true); setError("");
|
|
2168
|
+
try { return await fn(); } catch (e) { setError(e.message || String(e)); return undefined; } finally { operation.current = false; setBusy(false); }
|
|
2169
|
+
};
|
|
2170
|
+
const adopt = (draft) => { setSelected(draft); setForm(draftForm(draft)); setPreview(null); setAcknowledged(false); setRemove(false); setCopy(false); setError(""); setNotice(""); };
|
|
2171
|
+
useEffect(() => { if (active) { setPreview(null); setAcknowledged(false); run(reload); } }, [active]);
|
|
2172
|
+
useEffect(() => {
|
|
2173
|
+
const guard = (event) => { if (dirty) { event.preventDefault(); event.returnValue = ""; } };
|
|
2174
|
+
window.addEventListener("beforeunload", guard); return () => window.removeEventListener("beforeunload", guard);
|
|
2175
|
+
}, [dirty]);
|
|
2176
|
+
const save = async () => { const value = await draftApi("update", { ...form, id: selected.id, revision: selected.revision }); adopt(value); await reload(); setNotice(dt("草稿已保存,邮件未发送。", "Draft saved. No email was sent.")); return value; };
|
|
2177
|
+
const switchTo = async (target) => {
|
|
2178
|
+
if (target.kind === "new") adopt(await draftApi("create", { account: "", to: "", cc: "", subject: "", text: "" }));
|
|
2179
|
+
else adopt(await draftApi("get", { id: target.id }));
|
|
2180
|
+
await reload(); setPending(null);
|
|
2181
|
+
};
|
|
2182
|
+
const requestSwitch = (target) => { if (dirty) setPending(target); else run(() => switchTo(target)); };
|
|
2183
|
+
const field = (key, zh, en, multiline) => h("div", { className: "dshe-field", key }, [
|
|
2184
|
+
h("label", { htmlFor: "dshe-draft-" + key }, dt(zh, en)),
|
|
2185
|
+
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(""); } }),
|
|
2186
|
+
]);
|
|
2187
|
+
const review = preview && h("section", { className: "dshe-review", "aria-label": dt("发送前预览", "Review before sending") }, [
|
|
2188
|
+
h("h3", null, dt("发送前预览", "Review before sending")),
|
|
2189
|
+
h("p", null, dt("发件人:", "From: ") + (preview.from.name ? preview.from.name + " <" + preview.from.address + ">" : preview.from.address)),
|
|
2190
|
+
h("p", null, dt("主题:", "Subject: ") + preview.draft.subject),
|
|
2191
|
+
h("ul", { className: "dshe-match-list" }, preview.matching.rows.map((row, index) => h("li", { key: index }, (row.field === "cc" ? "Cc: " : "To: ") + row.address + " · " + (row.matched ? dt("匹配可信规则", "Matches a trusted rule") : dt("需要确认", "Needs confirmation"))))),
|
|
2192
|
+
h("pre", null, preview.draft.text || dt("(空正文)", "(Empty body)")),
|
|
2193
|
+
preview.attachments.length ? h("ul", { className: "dshe-files" }, preview.attachments.map(file => h("li", { key: file.id }, [
|
|
2194
|
+
h("span", null, file.filename + " · " + Math.ceil(file.size / 1024) + " KiB"),
|
|
2195
|
+
h("a", { href: DRAFT_ROUTE + "?preview=" + encodeURIComponent(preview.id) + "&attachment=" + encodeURIComponent(file.id), download: file.filename }, dt("下载检查", "Download to check")),
|
|
2196
|
+
]))) : h("p", null, dt("没有附件", "No attachments")),
|
|
2197
|
+
h("div", { className: "dshe-hint" }, dt("附件已固定为本次预览快照。10 分钟后需重新预览;修改草稿、账号或规则也会使预览失效。", "Attachments are fixed to this preview. Review again after 10 minutes or when the draft, account or rules change.")),
|
|
2198
|
+
h("label", { className: "dshe-check" }, [h("input", { type: "checkbox", checked: acknowledged, disabled: busy, onChange: event => setAcknowledged(event.target.checked) }), dt("我已核对发件人、所有收件人、正文和附件", "I checked the sender, all recipients, content and attachments")]),
|
|
2199
|
+
h("div", { className: "dshe-actions" }, [
|
|
2200
|
+
h("button", { type: "button", className: "dshe-btn", disabled: busy, onClick: () => run(async () => { await draftApi("cancelPreview", { id: selected.id }); setPreview(null); setAcknowledged(false); setNotice(dt("预览已取消,邮件未发送。", "Review cancelled. No email was sent.")); }) }, dt("取消预览", "Cancel review")),
|
|
2201
|
+
h("button", { type: "button", className: "dshe-btn primary", disabled: busy || !acknowledged || dirty, onClick: () => run(async () => {
|
|
2202
|
+
try { const result = await draftApi("send", { id: selected.id, preview: preview.id, confirmed: true }); adopt(result.draft); }
|
|
2203
|
+
finally { setPreview(null); setAcknowledged(false); const fresh = await reload(); const current = fresh.drafts.find(draft => draft.id === selected.id); if (current && current.state !== "draft") adopt(current); }
|
|
2204
|
+
}) }, busy ? dt("正在提交…", "Submitting…") : dt("确认发送", "Confirm send")),
|
|
2205
|
+
]),
|
|
2206
|
+
]);
|
|
2207
|
+
return h("section", { className: "dshe-panel", "aria-label": dt("邮件草稿", "Email drafts") }, [
|
|
2208
|
+
h("div", { className: "dshe-actions" }, [
|
|
2209
|
+
h("button", { type: "button", className: "dshe-btn primary", disabled: busy, onClick: () => requestSwitch({ kind: "new" }) }, dt("新建草稿", "New draft")),
|
|
2210
|
+
h("button", { type: "button", className: "dshe-btn", disabled: busy, onClick: () => run(async () => { const next = await reload(); if (selected && !dirty) { const saved = next.drafts.find(draft => draft.id === selected.id); if (saved) adopt(saved); } }) }, dt("刷新列表", "Refresh list")),
|
|
2211
|
+
]),
|
|
2212
|
+
h("p", { className: "dshe-hint" }, dt("草稿只保存在本机。模型可用 email_draft 准备内容,你在这里编辑、保存和确认发送。", "Drafts stay on this device. Ask the model to prepare one with email_draft, then edit, save and confirm here.")),
|
|
2213
|
+
pending ? h("div", { className: "dshe-alert info", role: "alert" }, [dt("当前草稿有未保存的编辑。", "The current draft has unsaved edits."), h("div", { className: "dshe-actions" }, [
|
|
2214
|
+
h("button", { type: "button", className: "dshe-btn", disabled: busy || locked, onClick: () => run(async () => { await save(); await switchTo(pending); }) }, dt("保存并切换", "Save and switch")),
|
|
2215
|
+
h("button", { type: "button", className: "dshe-btn", disabled: busy, onClick: () => run(() => switchTo(pending)) }, dt("放弃编辑并切换", "Discard edits and switch")),
|
|
2216
|
+
h("button", { type: "button", className: "dshe-btn", onClick: () => setPending(null) }, dt("保留编辑", "Keep editing")),
|
|
2217
|
+
])]) : null,
|
|
2218
|
+
error ? h("div", { className: "dshe-alert error", role: "alert" }, [error, selected ? h("button", { type: "button", className: "dshe-btn", disabled: busy, onClick: () => requestSwitch({ kind: "open", id: selected.id }) }, dt("重新打开服务器版本", "Reopen saved version")) : null]) : null,
|
|
2219
|
+
notice ? h("div", { className: "dshe-alert info", role: "status" }, notice) : null,
|
|
2220
|
+
h("div", { className: "dshe-workbench" }, [
|
|
2221
|
+
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."))),
|
|
2222
|
+
selected && form ? h("div", { className: "dshe-draft-editor" }, [
|
|
2223
|
+
h("div", { role: "status", className: "dshe-hint" }, dirty ? dt("有未保存的编辑", "Unsaved edits") : draftState(selected.state)),
|
|
2224
|
+
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))])]),
|
|
2225
|
+
field("to", "收件人 To", "Recipients To"), field("cc", "抄送 Cc", "Copy Cc"), field("subject", "主题", "Subject"), field("text", "正文", "Message", true),
|
|
2226
|
+
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]))),
|
|
2227
|
+
!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,
|
|
2228
|
+
!locked ? h("div", { className: "dshe-actions" }, [
|
|
2229
|
+
h("button", { type: "button", className: "dshe-btn", disabled: busy || !dirty, onClick: () => run(save) }, dt("保存草稿", "Save draft")),
|
|
2230
|
+
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")),
|
|
2231
|
+
]) : null,
|
|
2232
|
+
review,
|
|
2233
|
+
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,
|
|
2234
|
+
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,
|
|
2235
|
+
h("div", { className: "dshe-local-actions" }, [
|
|
2236
|
+
h("button", { type: "button", className: "dshe-btn", disabled: busy || selected.state === "sending" || dirty, onClick: () => setCopy(true) }, dt("复制为新草稿", "Copy to a new draft")),
|
|
2237
|
+
h("button", { type: "button", className: "dshe-btn", disabled: busy || selected.state === "sending", onClick: () => setRemove(true) }, dt("删除本地草稿", "Delete local draft")),
|
|
2238
|
+
]),
|
|
2239
|
+
copy ? h("div", { className: "dshe-alert info" }, [dt("会复制内容及附件为一份新的未发送草稿。若上次已提交或结果不明,请先核对邮箱,避免重复投递。", "Copies content and attachments to a new unsent draft. Check the mailbox first if the original was submitted or its result is uncertain."), h("div", { className: "dshe-actions" }, [h("button", { type: "button", className: "dshe-btn", disabled: busy, onClick: () => run(async () => { adopt(await draftApi("copy", { id: selected.id, revision: selected.revision, confirmed: true })); await reload(); }) }, dt("确认复制", "Confirm copy")), h("button", { type: "button", className: "dshe-btn", onClick: () => setCopy(false) }, dt("取消", "Cancel"))])]) : null,
|
|
2240
|
+
remove ? h("div", { className: "dshe-alert info" }, [dt("只删除本地草稿与插件上传的附件副本;引用的原文件和邮箱中的邮件不会删除。", "Deletes this local draft and plugin-uploaded copies. Original files and mailbox messages stay intact."), h("div", { className: "dshe-actions" }, [h("button", { type: "button", className: "dshe-btn", disabled: busy, onClick: () => run(async () => { await draftApi("delete", { id: selected.id, revision: selected.revision, confirmed: true }); setSelected(null); setForm(null); setRemove(false); await reload(); }) }, dt("确认删除", "Confirm delete")), h("button", { type: "button", className: "dshe-btn", onClick: () => setRemove(false) }, dt("取消", "Cancel"))])]) : null,
|
|
2241
|
+
]) : h("div", { className: "dshe-editor-empty" }, dt("选择一份草稿,或新建草稿开始编辑。", "Select a draft or create one to start editing.")),
|
|
2242
|
+
]),
|
|
2243
|
+
]);
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
function RecipientRules({ active, onSaved }) {
|
|
2247
|
+
const [data, setData] = useState(() => rulesUiCache && rulesUiCache.data), [account, setAccount] = useState(() => rulesUiCache && rulesUiCache.account || ""), [form, setForm] = useState(() => rulesUiCache && rulesUiCache.form || { addresses: "", domains: "", skipApproval: false });
|
|
2248
|
+
const [base, setBase] = useState(() => rulesUiCache && rulesUiCache.base || ""), [busy, setBusy] = useState(false), [error, setError] = useState(""), [notice, setNotice] = useState(""), [pending, setPending] = useState(null), [to, setTo] = useState(() => rulesUiCache && rulesUiCache.to || ""), [cc, setCc] = useState(() => rulesUiCache && rulesUiCache.cc || ""), [match, setMatch] = useState(null);
|
|
2249
|
+
const operation = useRef(false), dirty = data && JSON.stringify(form) !== base;
|
|
2250
|
+
useEffect(() => { rulesUiCache = { data, account, form, base, to, cc }; }, [data, account, form, base, to, cc]);
|
|
2251
|
+
const choose = (next, name) => { const rule = next.policies[name] || { addresses: [], domains: [], skipApproval: false }, fields = { addresses: rule.addresses.join("\n"), domains: rule.domains.join("\n"), skipApproval: rule.skipApproval }; setAccount(name); setForm(fields); setBase(JSON.stringify(fields)); setMatch(null); setPending(null); };
|
|
2252
|
+
const run = async (fn) => { if (operation.current) return; operation.current = true; setBusy(true); setError(""); try { await fn(); } catch (e) { setError(e.message || String(e)); } finally { operation.current = false; setBusy(false); } };
|
|
2253
|
+
const load = async () => { const next = await draftApi("rules"); setData(next); choose(next, next.accounts.includes(account) ? account : next.accounts[0] || ""); };
|
|
2254
|
+
useEffect(() => { if (active && !dirty) run(load); }, [active]);
|
|
2255
|
+
useEffect(() => { const guard = event => { if (dirty) { event.preventDefault(); event.returnValue = ""; } }; window.addEventListener("beforeunload", guard); return () => window.removeEventListener("beforeunload", guard); }, [dirty]);
|
|
2256
|
+
const policy = () => ({ skipApproval: form.skipApproval, addresses: form.addresses.split(/\r?\n/).map(row => row.trim()).filter(Boolean), domains: form.domains.split(/\r?\n/).map(row => row.trim()).filter(Boolean) });
|
|
2257
|
+
const save = async () => { const next = await draftApi("saveRules", { account, policy: policy(), revision: data.revision }); setData(next); choose(next, account); onSaved(next.revision); setNotice(dt("此账号规则已保存。", "Rules saved for this account.")); return next; };
|
|
2258
|
+
const change = patch => { setForm(current => ({ ...current, ...patch })); setMatch(null); setNotice(""); };
|
|
2259
|
+
const request = target => { if (dirty) setPending(target); else if (target.kind === "reload") run(load); else choose(data, target.name); };
|
|
2260
|
+
return h("section", { className: "dshe-panel dshe-rule-panel", "aria-label": dt("可信收件人规则", "Trusted recipients") }, [
|
|
2261
|
+
h("p", { className: "dshe-hint" }, dt("默认每次确认。主动启用后,只有该账号的全部 To/Cc 都匹配时,email_send 才跳过插件确认;宿主权限仍然生效,草稿仍需手动确认。域名规则只匹配指定域名,不包含子域名。", "Approval is the default. When enabled, email_send skips this plugin's approval only if every To/Cc address matches this account's rules. Host permissions still apply, and drafts still need manual confirmation. Domain rules exclude subdomains.")),
|
|
2262
|
+
error ? h("div", { className: "dshe-alert error", role: "alert" }, error) : null,
|
|
2263
|
+
notice ? h("div", { className: "dshe-alert success", role: "status" }, notice) : null,
|
|
2264
|
+
data && data.sendApproval === false ? h("div", { className: "dshe-alert info" }, dt("全局发信确认目前已关闭;这份规则不会额外拦截 email_send。", "Global send approval is currently off. These rules do not add another email_send gate.")) : null,
|
|
2265
|
+
h("div", { className: "dshe-field" }, [h("label", { htmlFor: "dshe-rule-account" }, dt("规则所属账号", "Account for these rules")), h("select", { id: "dshe-rule-account", value: account, disabled: busy, onChange: event => request({ kind: "account", name: event.target.value }) }, [h("option", { value: "", disabled: true }, dt("请选择已配置账号", "Choose a configured account")), ...(data ? data.accounts : []).map(name => h("option", { key: name, value: name }, name))])]),
|
|
2266
|
+
pending ? h("div", { className: "dshe-alert info" }, [dt("规则还未保存。", "Rules have unsaved edits."), h("div", { className: "dshe-actions" }, [h("button", { type: "button", className: "dshe-btn", disabled: busy, onClick: () => run(async () => { const next = await save(); if (pending.kind === "reload") await load(); else choose(next, pending.name); }) }, dt("保存并切换", "Save and switch")), h("button", { type: "button", className: "dshe-btn", disabled: busy, onClick: () => { if (pending.kind === "reload") run(load); else choose(data, pending.name); } }, dt("放弃编辑并切换", "Discard edits and switch")), h("button", { type: "button", className: "dshe-btn", onClick: () => setPending(null) }, dt("保留编辑", "Keep editing"))])]) : null,
|
|
2267
|
+
h("div", { className: "dshe-field" }, [h("label", { htmlFor: "dshe-rule-addresses" }, dt("可信邮箱地址(每行一个)", "Trusted email addresses (one per line)")), h("textarea", { id: "dshe-rule-addresses", rows: 4, value: form.addresses, placeholder: "name@example.com", disabled: busy || !account, onChange: event => change({ addresses: event.target.value }) })]),
|
|
2268
|
+
h("div", { className: "dshe-field" }, [h("label", { htmlFor: "dshe-rule-domains" }, dt("可信域名(每行一个,匹配该域所有邮箱)", "Trusted domains (one per line, all mailboxes in that domain)")), h("textarea", { id: "dshe-rule-domains", rows: 3, value: form.domains, placeholder: "example.com", disabled: busy || !account, onChange: event => change({ domains: event.target.value }) })]),
|
|
2269
|
+
h("label", { className: "dshe-check" }, [h("input", { type: "checkbox", checked: form.skipApproval, disabled: busy || !account, onChange: event => change({ skipApproval: event.target.checked }) }), dt("允许全部匹配时跳过 email_send 的插件确认", "Skip plugin approval for email_send when all addresses match")]),
|
|
2270
|
+
h("div", { className: "dshe-actions" }, [h("button", { type: "button", className: "dshe-btn primary", disabled: busy || !dirty || !account || data && !data.writable, onClick: () => run(save) }, dt("保存此账号规则", "Save rules for this account")), h("button", { type: "button", className: "dshe-btn", disabled: busy, onClick: () => request({ kind: "reload" }) }, dt("重新加载规则", "Reload rules"))]),
|
|
2271
|
+
h("div", { className: "dshe-hint", role: "status" }, dirty ? dt("未保存;匹配预览使用当前编辑的规则。", "Unsaved; matching uses the rules you are editing.") : dt("已保存的规则才会作用于模型发信。", "Only saved rules affect model-initiated sends.")),
|
|
2272
|
+
h("div", { className: "dshe-field" }, [h("label", { htmlFor: "dshe-rule-to" }, dt("试填收件人 To", "Try recipients To")), h("input", { id: "dshe-rule-to", type: "text", value: to, disabled: busy, onChange: event => { setTo(event.target.value); setMatch(null); } })]),
|
|
2273
|
+
h("div", { className: "dshe-field" }, [h("label", { htmlFor: "dshe-rule-cc" }, dt("试填抄送 Cc", "Try copy Cc")), h("input", { id: "dshe-rule-cc", type: "text", value: cc, disabled: busy, onChange: event => { setCc(event.target.value); setMatch(null); } })]),
|
|
2274
|
+
h("button", { type: "button", className: "dshe-btn", disabled: busy || !account, onClick: () => run(async () => setMatch(await draftApi("matchRules", { account, policy: policy(), to, cc }))) }, dt("检查规则匹配(不发送)", "Check matches (no email sent)")),
|
|
2275
|
+
match ? h("div", { className: "dshe-review" }, [h("p", null, match.skipsApproval ? dt("全部 To/Cc 匹配,可跳过插件确认。", "All To/Cc match; plugin approval can be skipped.") : dt("不会跳过插件确认。", "Plugin approval will remain.")), h("ul", { className: "dshe-match-list" }, match.rows.map((row, index) => h("li", { key: index }, row.field.toUpperCase() + ": " + row.address + " · " + (row.matched ? dt("匹配:", "Match: ") + row.value : dt("未匹配", "No match")))))]) : null,
|
|
2276
|
+
]);
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2135
2279
|
function EmailSettingsSection() {
|
|
2280
|
+
const [tab, setTab] = useState(emailActiveTab);
|
|
2281
|
+
useEffect(() => { emailActiveTab = tab; }, [tab]);
|
|
2136
2282
|
const [draft, setDraft] = useState(null);
|
|
2137
2283
|
const [snapshot, setSnapshot] = useState(undefined);
|
|
2138
2284
|
const [busy, setBusy] = useState(false);
|
|
@@ -2416,10 +2562,13 @@ function EmailSettingsSection() {
|
|
|
2416
2562
|
h("header", { className: "dshe-header" }, [
|
|
2417
2563
|
h("span", { className: "dshe-kicker" }, "dsh-email · IMAP/SMTP"),
|
|
2418
2564
|
h("h2", null, t("section.title")),
|
|
2419
|
-
h("p", null,
|
|
2565
|
+
h("p", null, dt("配置邮箱账号,编辑草稿并确认发送,或管理收件人规则。账号设置修改后会自动保存。", "Configure accounts, edit and confirm drafts, or manage recipient rules. Account settings save automatically.")),
|
|
2420
2566
|
]),
|
|
2421
|
-
status,
|
|
2422
|
-
h("
|
|
2567
|
+
tab === "accounts" ? status : null,
|
|
2568
|
+
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))),
|
|
2569
|
+
h("div", { id: "dshe-pane-drafts", role: "tabpanel", hidden: tab !== "drafts", className: "dshe-section-hidden" }, h(DraftWorkbench, { active: tab === "drafts" })),
|
|
2570
|
+
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 } })); } })),
|
|
2571
|
+
h("section", { id: "dshe-pane-accounts", role: "tabpanel", hidden: tab !== "accounts", className: "dshe-panel dshe-section-hidden" }, [
|
|
2423
2572
|
// 全局设置:发信确认和下载目录对所有账号一视同仁,不属于任何一张卡片,
|
|
2424
2573
|
// 所以摆在卡片列表上方,而不是塞进某一张卡里。
|
|
2425
2574
|
h("div", { className: "dshe-global" }, [
|
package/lib/config.d.ts
CHANGED
|
@@ -108,6 +108,8 @@ export interface AccountConfig {
|
|
|
108
108
|
inboxFolder?: string;
|
|
109
109
|
}
|
|
110
110
|
export interface EmailConfig extends AccountConfig {
|
|
111
|
+
/** Per-account recipient rules, independent of IMAP/SMTP connection settings. */
|
|
112
|
+
trustedRecipientsYaml?: string;
|
|
111
113
|
/** Ask the user for approval before email_send. Default true. */
|
|
112
114
|
sendApproval?: boolean;
|
|
113
115
|
/** Plain-text body cap for email_read. Default 20000. */
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { EmailSendResult } from './types.js';
|
|
2
|
+
export type DraftState = 'draft' | 'sending' | 'sent' | 'uncertain';
|
|
3
|
+
export interface DraftAttachment {
|
|
4
|
+
id: string;
|
|
5
|
+
path: string;
|
|
6
|
+
filename: string;
|
|
7
|
+
owned: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface EmailDraft {
|
|
10
|
+
id: string;
|
|
11
|
+
revision: number;
|
|
12
|
+
account: string;
|
|
13
|
+
to: string;
|
|
14
|
+
cc: string;
|
|
15
|
+
subject: string;
|
|
16
|
+
text: string;
|
|
17
|
+
attachments: DraftAttachment[];
|
|
18
|
+
state: DraftState;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
updatedAt: string;
|
|
21
|
+
attemptAt?: string;
|
|
22
|
+
receipt?: EmailSendResult;
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface DraftFields {
|
|
26
|
+
account: string;
|
|
27
|
+
to: string;
|
|
28
|
+
cc: string;
|
|
29
|
+
subject: string;
|
|
30
|
+
text: string;
|
|
31
|
+
}
|
|
32
|
+
export declare class DraftError extends Error {
|
|
33
|
+
readonly code: string;
|
|
34
|
+
constructor(message: string, code?: string);
|
|
35
|
+
}
|
|
36
|
+
export declare function draftFields(input: unknown): DraftFields;
|
|
37
|
+
/** Private, bounded local drafts. File locks protect separate DSH processes and atomic replacement preserves old data on failure. */
|
|
38
|
+
export declare class DraftStore {
|
|
39
|
+
readonly root: string;
|
|
40
|
+
private readonly file;
|
|
41
|
+
constructor(home?: string);
|
|
42
|
+
list(): Promise<EmailDraft[]>;
|
|
43
|
+
private load;
|
|
44
|
+
private write;
|
|
45
|
+
private withLock;
|
|
46
|
+
private mutate;
|
|
47
|
+
get(id: string): Promise<EmailDraft>;
|
|
48
|
+
create(fields: DraftFields, attachments?: DraftAttachment[], signal?: AbortSignal): Promise<EmailDraft>;
|
|
49
|
+
change(id: string, expectedRevision: unknown, action: (draft: EmailDraft) => void, signal?: AbortSignal): Promise<EmailDraft>;
|
|
50
|
+
remove(id: string, revision: unknown, signal?: AbortSignal): Promise<void>;
|
|
51
|
+
removeOwnedFile(id: string, file: DraftAttachment): Promise<void>;
|
|
52
|
+
sendLock<T>(id: string, action: (signal: AbortSignal) => Promise<T>, signal?: AbortSignal): Promise<T>;
|
|
53
|
+
uploadPath(id: string, filename: string): {
|
|
54
|
+
path: string;
|
|
55
|
+
filename: string;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readFile, realpath, rename, stat, unlink } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
5
|
+
import lockfile from 'proper-lockfile';
|
|
6
|
+
export class DraftError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
constructor(message, code = 'draft-invalid') {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const isId = (id) => typeof id === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(id);
|
|
14
|
+
export function draftFields(input) {
|
|
15
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
16
|
+
throw new DraftError('草稿内容必须是对象');
|
|
17
|
+
const raw = input, fields = {};
|
|
18
|
+
for (const [key, cap] of [['account', 200], ['to', 4096], ['cc', 4096], ['subject', 500], ['text', 100 * 1024]]) {
|
|
19
|
+
const value = raw[key] ?? '';
|
|
20
|
+
if (typeof value !== 'string' || Buffer.byteLength(value) > cap || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value) || (key !== 'text' && /[\r\n]/.test(value)))
|
|
21
|
+
throw new DraftError(key === 'text' ? '正文最多 100 KiB,且不能包含非文本控制字符' : key + ' 内容过长或包含换行/控制字符');
|
|
22
|
+
fields[key] = key === 'text' ? value : value.trim();
|
|
23
|
+
}
|
|
24
|
+
return fields;
|
|
25
|
+
}
|
|
26
|
+
/** Private, bounded local drafts. File locks protect separate DSH processes and atomic replacement preserves old data on failure. */
|
|
27
|
+
export class DraftStore {
|
|
28
|
+
root;
|
|
29
|
+
file;
|
|
30
|
+
constructor(home = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh')) {
|
|
31
|
+
this.root = resolve(home, 'data/dsh-email');
|
|
32
|
+
this.file = join(this.root, 'drafts-v1.json');
|
|
33
|
+
}
|
|
34
|
+
async list() { return (await this.load()).drafts; }
|
|
35
|
+
async load() {
|
|
36
|
+
try {
|
|
37
|
+
if ((await stat(this.file)).size > 16 * 1024 * 1024)
|
|
38
|
+
throw new Error('too large');
|
|
39
|
+
const doc = JSON.parse(await readFile(this.file, 'utf8'));
|
|
40
|
+
if (doc.version !== 1 || !Array.isArray(doc.drafts) || doc.drafts.length > 50 || new Set(doc.drafts.map(d => d.id)).size !== doc.drafts.length)
|
|
41
|
+
throw new Error('invalid document');
|
|
42
|
+
for (const draft of doc.drafts) {
|
|
43
|
+
if (!isId(draft.id) || !Number.isSafeInteger(draft.revision) || draft.revision < 1 || !['draft', 'sending', 'sent', 'uncertain'].includes(draft.state))
|
|
44
|
+
throw new Error('invalid draft');
|
|
45
|
+
draftFields(draft);
|
|
46
|
+
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
|
+
throw new Error('invalid attachments');
|
|
48
|
+
}
|
|
49
|
+
return doc;
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error?.code === 'ENOENT')
|
|
53
|
+
return { version: 1, drafts: [] };
|
|
54
|
+
throw new DraftError('本地草稿文件无法读取,已保留原文件;请检查存储位置或从备份恢复', 'draft-storage');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async write(doc) {
|
|
58
|
+
const data = JSON.stringify(doc, null, 2);
|
|
59
|
+
if (Buffer.byteLength(data) > 16 * 1024 * 1024)
|
|
60
|
+
throw new DraftError('草稿总量过大,请先删除不再需要的草稿');
|
|
61
|
+
const temporary = join(this.root, '.drafts-' + randomUUID() + '.json');
|
|
62
|
+
try {
|
|
63
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
64
|
+
try {
|
|
65
|
+
await handle.writeFile(data);
|
|
66
|
+
await handle.sync();
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
await handle.close();
|
|
70
|
+
}
|
|
71
|
+
await rename(temporary, this.file);
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
await unlink(temporary).catch(() => { });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async withLock(file, action, signal, wait = true) {
|
|
78
|
+
signal?.throwIfAborted();
|
|
79
|
+
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
80
|
+
const compromised = new AbortController();
|
|
81
|
+
let release;
|
|
82
|
+
try {
|
|
83
|
+
release = await lockfile.lock(file, { realpath: false, stale: 120000, update: 10000, retries: wait ? { retries: 10, factor: 1, minTimeout: 50, maxTimeout: 50 } : 0, onCompromised: error => compromised.abort(error) });
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error?.code === 'ELOCKED')
|
|
87
|
+
throw new DraftError('另一处 DSH 正在保存或发送这份草稿,请稍后重试', 'draft-busy');
|
|
88
|
+
throw new DraftError('无法锁定草稿存储,请检查目录权限', 'draft-storage');
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const current = signal ? AbortSignal.any([signal, compromised.signal]) : compromised.signal;
|
|
92
|
+
current.throwIfAborted();
|
|
93
|
+
return await action(current);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await release().catch(() => { });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async mutate(action, signal) {
|
|
100
|
+
return this.withLock(this.file, async (current) => { const doc = await this.load(); current.throwIfAborted(); const result = action(doc); current.throwIfAborted(); await this.write(doc); return result; }, signal);
|
|
101
|
+
}
|
|
102
|
+
async get(id) {
|
|
103
|
+
const draft = (await this.list()).find(d => d.id === id);
|
|
104
|
+
if (!draft)
|
|
105
|
+
throw new DraftError('草稿不存在或已被移除', 'draft-missing');
|
|
106
|
+
return draft;
|
|
107
|
+
}
|
|
108
|
+
async create(fields, attachments = [], signal) {
|
|
109
|
+
if (attachments.length > 10 || attachments.some(file => !isId(file.id) || !isAbsolute(file.path) || typeof file.filename !== 'string' || typeof file.owned !== 'boolean'))
|
|
110
|
+
throw new DraftError('草稿最多 10 个有效本地附件');
|
|
111
|
+
return this.mutate(doc => {
|
|
112
|
+
if (doc.drafts.length >= 50)
|
|
113
|
+
throw new DraftError('已有 50 份草稿,请先删除不再需要的草稿');
|
|
114
|
+
const now = new Date().toISOString(), draft = { ...draftFields(fields), id: randomUUID(), revision: 1, attachments, state: 'draft', createdAt: now, updatedAt: now };
|
|
115
|
+
doc.drafts.unshift(draft);
|
|
116
|
+
return draft;
|
|
117
|
+
}, signal);
|
|
118
|
+
}
|
|
119
|
+
async change(id, expectedRevision, action, signal) {
|
|
120
|
+
return this.mutate(doc => {
|
|
121
|
+
const draft = doc.drafts.find(d => d.id === id);
|
|
122
|
+
if (!draft)
|
|
123
|
+
throw new DraftError('草稿不存在或已被移除', 'draft-missing');
|
|
124
|
+
if (draft.revision !== expectedRevision)
|
|
125
|
+
throw new DraftError('草稿已在另一处修改,请重新打开;当前编辑内容尚未覆盖服务器', 'draft-conflict');
|
|
126
|
+
action(draft);
|
|
127
|
+
draft.revision++;
|
|
128
|
+
draft.updatedAt = new Date().toISOString();
|
|
129
|
+
return draft;
|
|
130
|
+
}, signal);
|
|
131
|
+
}
|
|
132
|
+
async remove(id, revision, signal) {
|
|
133
|
+
const removed = await this.mutate(doc => {
|
|
134
|
+
const draft = doc.drafts.find(d => d.id === id);
|
|
135
|
+
if (!draft)
|
|
136
|
+
throw new DraftError('草稿已被移除', 'draft-missing');
|
|
137
|
+
if (draft.revision !== revision)
|
|
138
|
+
throw new DraftError('草稿已改变,请重新打开后删除', 'draft-conflict');
|
|
139
|
+
if (draft.state === 'sending')
|
|
140
|
+
throw new DraftError('邮件正在提交,请结束后再移除草稿', 'draft-busy');
|
|
141
|
+
doc.drafts = doc.drafts.filter(d => d.id !== id);
|
|
142
|
+
return draft;
|
|
143
|
+
}, signal);
|
|
144
|
+
for (const file of removed.attachments)
|
|
145
|
+
await this.removeOwnedFile(id, file);
|
|
146
|
+
}
|
|
147
|
+
async removeOwnedFile(id, file) {
|
|
148
|
+
if (!file.owned || !isId(id))
|
|
149
|
+
return;
|
|
150
|
+
try {
|
|
151
|
+
const root = await realpath(join(this.root, 'draft-uploads', id)), target = await realpath(file.path);
|
|
152
|
+
const comparable = (path) => process.platform === 'win32' ? path.toLowerCase() : path;
|
|
153
|
+
if (comparable(target).startsWith(comparable(root + sep)) && comparable(dirname(target)) === comparable(root))
|
|
154
|
+
await unlink(file.path);
|
|
155
|
+
}
|
|
156
|
+
catch { /* Never remove a referenced original or a path outside this draft's owned upload directory. */ }
|
|
157
|
+
}
|
|
158
|
+
async sendLock(id, action, signal) {
|
|
159
|
+
if (!isId(id))
|
|
160
|
+
throw new DraftError('草稿编号不合法');
|
|
161
|
+
return this.withLock(join(this.root, 'send-' + id), action, signal, false);
|
|
162
|
+
}
|
|
163
|
+
uploadPath(id, filename) {
|
|
164
|
+
if (!isId(id))
|
|
165
|
+
throw new DraftError('草稿编号不合法');
|
|
166
|
+
const safe = basename(filename).replace(/[\u0000-\u001f\u007f<>:"/\\|?*]/g, '_').slice(0, 160).replace(/[. ]+$/, '') || 'attachment';
|
|
167
|
+
return { path: join(this.root, 'draft-uploads', id, randomUUID() + '-' + safe), filename: safe };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { DraftStore, type EmailDraft } from './draft-store.js';
|
|
2
|
+
import type { EmailRuntime } from './runtime.js';
|
|
3
|
+
import type { EmailSettingsBackend } from './web.js';
|
|
4
|
+
export declare const EMAIL_DRAFT_ROUTE = "/api/dsh-email/drafts";
|
|
5
|
+
interface Options {
|
|
6
|
+
store?: DraftStore;
|
|
7
|
+
now?: () => number;
|
|
8
|
+
}
|
|
9
|
+
/** All mutations use the host's authenticated fetch carrier and an explicit UI action. */
|
|
10
|
+
export declare class EmailDraftBackend {
|
|
11
|
+
private readonly runtime;
|
|
12
|
+
private readonly settings;
|
|
13
|
+
private readonly options;
|
|
14
|
+
readonly store: DraftStore;
|
|
15
|
+
private readonly plans;
|
|
16
|
+
constructor(runtime: Pick<EmailRuntime, 'getEffectiveSettings' | 'getSettingsValue' | 'getPool'>, settings: Pick<EmailSettingsBackend, 'snapshot' | 'saveRecipientRules'>, options?: Options);
|
|
17
|
+
private now;
|
|
18
|
+
private prune;
|
|
19
|
+
private invalidate;
|
|
20
|
+
private connection;
|
|
21
|
+
private safe;
|
|
22
|
+
private bytes;
|
|
23
|
+
private attachments;
|
|
24
|
+
create(input: Record<string, unknown>, signal?: AbortSignal): Promise<EmailDraft>;
|
|
25
|
+
private recover;
|
|
26
|
+
action(body: Record<string, unknown>, signal?: AbortSignal): Promise<unknown>;
|
|
27
|
+
private writeUpload;
|
|
28
|
+
private upload;
|
|
29
|
+
fetch(request: Request): Promise<Response>;
|
|
30
|
+
}
|
|
31
|
+
export declare function installEmailDrafts(ctx: any, backend: EmailDraftBackend): void;
|
|
32
|
+
export {};
|