dsh-email 0.14.6 → 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 +102 -44
- package/README.en.md +25 -236
- package/README.md +25 -248
- 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 +176 -19
- 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 +23 -11
- 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 +17 -3
- package/package.json +11 -9
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)}",
|
|
@@ -751,9 +755,8 @@ function isOauthDraft(draft) {
|
|
|
751
755
|
|
|
752
756
|
/**
|
|
753
757
|
* 服务端卡片(永不含密码明文)-> 草稿。user 为空表示「就留空」,不是未加载。
|
|
754
|
-
*
|
|
755
|
-
*
|
|
756
|
-
* 时把一份会过期的端点抄回 YAML。卡片上的端点显示改从 presets 查(providerMetaText)。
|
|
758
|
+
* 卡片的 imap/smtp 是后端计算的生效端点,只用于显示,不写回 YAML。
|
|
759
|
+
* 它们可能包含用户明确指定的服务器,不能用服务商默认值代替。
|
|
757
760
|
* authKind/oauthState/oauthUser 是卡片的登录态投影,只用于显示与本地判定,
|
|
758
761
|
* 不进 draftToInput(它们不是 YAML 的字段,后端认的是账号自己的 oauth 段)。
|
|
759
762
|
*/
|
|
@@ -848,6 +851,8 @@ function applyPreset(draft, provider) {
|
|
|
848
851
|
const next = provider === undefined || provider === null ? "" : String(provider);
|
|
849
852
|
return Object.assign({}, draft, {
|
|
850
853
|
provider: next,
|
|
854
|
+
imap: undefined,
|
|
855
|
+
smtp: undefined,
|
|
851
856
|
password: "",
|
|
852
857
|
authKind: undefined,
|
|
853
858
|
oauthState: "none",
|
|
@@ -889,13 +894,13 @@ function draftToInput(draft) {
|
|
|
889
894
|
|
|
890
895
|
/**
|
|
891
896
|
* 半填账号:名字、邮箱地址、服务商三者缺一都算。服务商是账号的连接身份 —— 没有它
|
|
892
|
-
*
|
|
897
|
+
* 没有服务商且未填完整的自定义服务器时,就没有端点可展开。
|
|
893
898
|
* OAuth2 账号不要密码:有 (name, provider, user) 就算填完了。
|
|
894
899
|
*/
|
|
895
900
|
function accountIncomplete(draft) {
|
|
896
901
|
return String(draft.name || "").trim() === ""
|
|
897
902
|
|| String(draft.user || "").trim() === ""
|
|
898
|
-
|| String(draft.provider || "").trim() === "";
|
|
903
|
+
|| (String(draft.provider || "").trim() === "" && !(draft.imap?.host && draft.smtp?.host));
|
|
899
904
|
}
|
|
900
905
|
|
|
901
906
|
/** 改过名字后草稿要跟着键走:旧键删掉,整份草稿(含 imap/smtp)搬到新键下。 */
|
|
@@ -938,25 +943,32 @@ function presetEndpointText(endpoint) {
|
|
|
938
943
|
}
|
|
939
944
|
|
|
940
945
|
/**
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
* 内置预设显示内置名 + 它的端点;自定义预设显示 label(没 label 用预设名)+ 端点;
|
|
944
|
-
* 名字连预设都没定义时明说「未定义预设」;压根没选服务商就直说。
|
|
946
|
+
* 卡片显示后端的生效服务器,含手动覆盖;换服务商的未保存草稿才查预设。
|
|
947
|
+
* 保存后的新快照优先,避免自定义预设更新后继续显示旧地址。
|
|
945
948
|
*/
|
|
946
|
-
function providerMetaText(draft, presets) {
|
|
949
|
+
function providerMetaText(draft, presets, cards) {
|
|
947
950
|
const provider = String(draft.provider || "").trim();
|
|
948
|
-
|
|
951
|
+
const saved = (cards || []).find(card => card.name === draft.name)
|
|
952
|
+
|| (cards || []).find(card => card.name === draft.originalName);
|
|
953
|
+
const actual = saved && String(saved.provider || "").trim() === provider ? saved : draft;
|
|
954
|
+
const endpoints = fallback => ({
|
|
955
|
+
imap: actual.imap && actual.imap.host ? actual.imap : fallback && fallback.imap,
|
|
956
|
+
smtp: actual.smtp && actual.smtp.host ? actual.smtp : fallback && fallback.smtp,
|
|
957
|
+
});
|
|
958
|
+
const describe = (name, fallback) => {
|
|
959
|
+
const target = endpoints(fallback);
|
|
960
|
+
return name + t("card.builtinEndpoints", { imap: presetEndpointText(target.imap), smtp: presetEndpointText(target.smtp) });
|
|
961
|
+
};
|
|
962
|
+
if (provider === "") return actual.imap?.host || actual.smtp?.host ? describe(t("provider.custom")) : t("card.noProvider");
|
|
949
963
|
const entry = PROVIDERS.filter(([value]) => value === provider)[0];
|
|
950
964
|
if (entry) {
|
|
951
965
|
const builtin = (presets && presets.builtin && presets.builtin[provider]) || undefined;
|
|
952
|
-
|
|
953
|
-
return t(entry[1]) + t("card.builtinEndpoints", { imap: presetEndpointText(builtin.imap), smtp: presetEndpointText(builtin.smtp) });
|
|
966
|
+
return describe(t(entry[1]), builtin);
|
|
954
967
|
}
|
|
955
968
|
const custom = (presets && presets.custom) || {};
|
|
956
969
|
if (!Object.prototype.hasOwnProperty.call(custom, provider)) return provider + t("provider.undefinedPresetSuffix");
|
|
957
970
|
const preset = custom[provider] || undefined;
|
|
958
|
-
|
|
959
|
-
return presetLabelOf(provider, preset) + t("card.customEndpoint", { endpoint: presetEndpointText(preset.imap) });
|
|
971
|
+
return describe(presetLabelOf(provider, preset), preset);
|
|
960
972
|
}
|
|
961
973
|
|
|
962
974
|
// ---------------------------------------------------------------------------
|
|
@@ -1717,7 +1729,7 @@ function AccountCardsEditor(props) {
|
|
|
1717
1729
|
t("card.meta", {
|
|
1718
1730
|
address: draft.user || t("card.noAddress"),
|
|
1719
1731
|
folder: draft.inboxFolder || "INBOX",
|
|
1720
|
-
provider: providerMetaText(draft, presets),
|
|
1732
|
+
provider: providerMetaText(draft, presets, cards),
|
|
1721
1733
|
})),
|
|
1722
1734
|
status
|
|
1723
1735
|
? h("div", { className: "dshe-hint" + (status.kind === "error" ? " dshe-acc-warn" : "") }, status.text)
|
|
@@ -2124,7 +2136,149 @@ function ServerPresetsEditor(props) {
|
|
|
2124
2136
|
]);
|
|
2125
2137
|
}
|
|
2126
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
|
+
|
|
2127
2279
|
function EmailSettingsSection() {
|
|
2280
|
+
const [tab, setTab] = useState(emailActiveTab);
|
|
2281
|
+
useEffect(() => { emailActiveTab = tab; }, [tab]);
|
|
2128
2282
|
const [draft, setDraft] = useState(null);
|
|
2129
2283
|
const [snapshot, setSnapshot] = useState(undefined);
|
|
2130
2284
|
const [busy, setBusy] = useState(false);
|
|
@@ -2408,10 +2562,13 @@ function EmailSettingsSection() {
|
|
|
2408
2562
|
h("header", { className: "dshe-header" }, [
|
|
2409
2563
|
h("span", { className: "dshe-kicker" }, "dsh-email · IMAP/SMTP"),
|
|
2410
2564
|
h("h2", null, t("section.title")),
|
|
2411
|
-
h("p", null,
|
|
2565
|
+
h("p", null, dt("配置邮箱账号,编辑草稿并确认发送,或管理收件人规则。账号设置修改后会自动保存。", "Configure accounts, edit and confirm drafts, or manage recipient rules. Account settings save automatically.")),
|
|
2412
2566
|
]),
|
|
2413
|
-
status,
|
|
2414
|
-
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" }, [
|
|
2415
2572
|
// 全局设置:发信确认和下载目录对所有账号一视同仁,不属于任何一张卡片,
|
|
2416
2573
|
// 所以摆在卡片列表上方,而不是塞进某一张卡里。
|
|
2417
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 {};
|