@aiti-ai/aiti-mail 0.0.0-stage → 0.1.7

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/AGENTS.md ADDED
@@ -0,0 +1,28 @@
1
+ # aiti-mail 扩展(部署目录直改指南)
2
+
3
+ ## 结构
4
+ - 运行时文件平铺在本目录根(index.ts / core.cjs …,宿主 loader 按相对路径加载——**禁手改运行时平铺文件**,改 src/ 再 build)
5
+ - `src/` 源码(client/src + 服务端 ts/cjs)· `dist/` 构建产物(禁手改)· `tools/` 构建/部署脚本
6
+ - npm 包形态的扩展:本目录 package.json 含 version 与 aitiConfigs 声明
7
+
8
+ ## 小改现场流程(改文案/样式/小逻辑)
9
+ 1. 改 `src/` 下对应源码
10
+ 2. `node tools/build.mjs`(client 有构建产物的扩展;纯服务端改动跳过)
11
+ 3. `curl -X POST http://127.0.0.1:3001/api/extensions/reload`(dev;prod 3002;外部经 nginx 带 basePath:/dev、/mac、/mdev、/win、/wdev → `/xxx/api/extensions/reload`)
12
+ 4. 浏览器刷新页面验证
13
+
14
+ ## 大改回源码仓
15
+ - 源码仓:`extensions/aiti-mail/`(git 管理,deploy 同步回本目录)
16
+ - 改完在源码仓跑 `node tools/deploy.mjs`(或仓库根 `node scripts/deploy-all.mjs --only aiti-mail`)
17
+
18
+ ## 数据目录(数据分层:config / cache)
19
+ - `ctx.configDir` 根级 = 配置与关键小状态(aiti-configs 备份范围;小、珍贵、人可读)
20
+ - `ctx.cacheDir`(= `<configDir>/cache/`)= 可删缓存:db/venv/日志/大文件;rm -rf 后扩展下次访问自动重建
21
+ - `ctx.dataDir` 已移除(访问即抛)——新代码禁用
22
+ - 本扩展白名单(永不为 cache 的配置文件)见 package.json `aitiConfigs.configFiles`
23
+
24
+ ## 红线
25
+ - 禁轮询(setInterval 拉取);需要等待用事件/回调/SSE(桥的 SSE、aiti-event-bus topics)
26
+ - 禁重启生产服务(pi-web 3002 等 restart 须用户明确同意)
27
+ - 禁手改 dist/ 与运行时平铺文件;跨扩展通信走 ctx.services(禁 globalThis.__ext 直穿)
28
+ - 服务端:SQLite 一律 WAL + 分批小事务(批间 setImmediate 让步);禁 execFileSync 调 >1s 外部程序;>8MB 文件禁同步读
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 aiti-ai contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,273 @@
1
+ /**
2
+ * check-client —— 扩展 client .mjs/.js 的 TS 残留 tripwire(零依赖,纯 node)。
3
+ *
4
+ * 背景:aiti-plan-review 事故(docs/aiti-ext-client-hardening-plan.md §1)——
5
+ * `useState<any>(null)` 解析为合法 JS 比较表达式 `useState < any > (null)`,
6
+ * node --check / TS 解析器 / babel 全不报错,只有运行时才炸。纯语法检查
7
+ * 原理性无效,只能启发式规则 + 渲染期错误边界兜底。
8
+ *
9
+ * 用法:
10
+ * CLI node check-client.mjs <file|dir>... FAIL → exit 1
11
+ * 自测 node check-client.mjs --selftest fixtures 全过 → exit 0
12
+ * API lintClientSource(src) → violations[] { line, level, rule, text }
13
+ * lintPath(path) → { files, violations }(文件或目录,递归)
14
+ * checkClientDir(dir,{log}) FAIL → throw(deploy 脚本用)
15
+ *
16
+ * 词法:规则匹配前先剥离注释/字符串/模板字面量(${} 插值内代码保留检查)——
17
+ * aiti-editor 的 Monaco TS stub 模板串、JSDoc @return 都是合法内容,不剥全误报。
18
+ * 已知极限:regex 字面量含引号等 lexer 歧义允许漏报(tripwire 定位非编译器)。
19
+ *
20
+ * 豁免:目标行或其上一行含 `check-client:allow` 即跳过该行。
21
+ * 权威源:本文件;aiti-extman 部署副本由其 tools/deploy.mjs 拷贝(改动两处同步)。
22
+ */
23
+ import { readdirSync, readFileSync, statSync } from "node:fs";
24
+ import { join, resolve } from "node:path";
25
+
26
+ // ── 规则(分级)──────────────────────────────────────────────────────────────
27
+
28
+ const RULES = [
29
+ // FAIL:合法 JS 但运行时必炸 / 纯 TS 语法
30
+ { level: "FAIL", re: /\b(useState|useRef|useMemo|useCallback|useReducer|useImperativeHandle)\s*</, why: "React hook 泛型(运行时必炸)" },
31
+ // 注:含「any/unknown/string」字样的规则用字符串构 RegExp——本文件自身也
32
+ // 会被扫,正则字面量里的 (?:any 会被自己命中(词法器不处理 regex 字面量),
33
+ // 字符串字面量则会被词法遮蔽。
34
+ { level: "FAIL", re: new RegExp("\\bas\\s+(?:" + "any|unknown|const)\\b"), why: "as 断言" },
35
+ { level: "FAIL", re: new RegExp(":\\s*(?:" + "any|unknown)\\b"), why: ": any/: unknown 注解" },
36
+ { level: "FAIL", re: /\b(?:import|export)\s+type\s/, why: "import/export type" },
37
+ { level: "FAIL", re: /\b(?:interface|enum)\s+[A-Za-z_$]/, why: "interface/enum 声明" },
38
+ // warn:疑似残留,人工确认,不阻断
39
+ { level: "warn", re: new RegExp(":\\s*(?:" + "string|number|boolean|void)\\b(?!\\s*[._\\w])"), why: "疑似类型注解" },
40
+ { level: "warn", re: /\bsatisfies\s/, why: "satisfies" },
41
+ { level: "warn", re: /\b(?:Record|Partial|Readonly|Omit|Pick)\s*</, why: "TS 工具类型" },
42
+ ];
43
+
44
+ // ── 词法剥离:注释/字符串/模板字面量 → 空格(保留换行,行号不漂移)─────────
45
+ export function stripLiterals(src) {
46
+ const out = src.split("");
47
+ const n = src.length;
48
+ const mask = (from, to) => {
49
+ for (let k = from; k < to && k < n; k++) if (out[k] !== "\n") out[k] = " ";
50
+ };
51
+ let mode = "code";
52
+ let depth = 0;
53
+ const stack = []; // 模板 ${ 插值进入时的 brace 深度(支持嵌套模板)
54
+ let i = 0;
55
+ while (i < n) {
56
+ const c = src[i];
57
+ const d = i + 1 < n ? src[i + 1] : "";
58
+ if (mode === "code") {
59
+ if (c === "/" && d === "/") {
60
+ const j = src.indexOf("\n", i);
61
+ const end = j === -1 ? n : j;
62
+ mask(i, end);
63
+ i = end;
64
+ } else if (c === "/" && d === "*") {
65
+ const j = src.indexOf("*/", i + 2);
66
+ const end = j === -1 ? n : j + 2;
67
+ mask(i, end);
68
+ i = end;
69
+ } else if (c === "'" || c === '"') {
70
+ let j = i + 1;
71
+ while (j < n && src[j] !== c && src[j] !== "\n") j += src[j] === "\\" ? 2 : 1;
72
+ if (j < n && src[j] === c) {
73
+ mask(i, j + 1);
74
+ i = j + 1;
75
+ } else {
76
+ // 未闭合到行尾 → 可能是 regex 字面量歧义,只遮开引号,按代码继续
77
+ out[i] = " ";
78
+ i++;
79
+ }
80
+ } else if (c === "`") {
81
+ mode = "tpl";
82
+ out[i] = " ";
83
+ i++;
84
+ } else if (c === "{") {
85
+ depth++;
86
+ i++;
87
+ } else if (c === "}") {
88
+ if (stack.length && depth === stack[stack.length - 1]) {
89
+ stack.pop();
90
+ mode = "tpl"; // 插值结束回模板
91
+ out[i] = " ";
92
+ } else {
93
+ depth = Math.max(0, depth - 1);
94
+ }
95
+ i++;
96
+ } else i++;
97
+ } else if (mode === "tpl") {
98
+ if (c === "\\") {
99
+ mask(i, Math.min(i + 2, n));
100
+ i += 2;
101
+ } else if (c === "`") {
102
+ mode = "code";
103
+ out[i] = " ";
104
+ i++;
105
+ } else if (c === "$" && d === "{") {
106
+ stack.push(depth);
107
+ mode = "code";
108
+ mask(i, i + 2);
109
+ i += 2;
110
+ } else {
111
+ if (c !== "\n") out[i] = " ";
112
+ i++;
113
+ }
114
+ } else {
115
+ // 不可达:line/block/字符串均在 code 分支内联处理
116
+ i++;
117
+ }
118
+ }
119
+ return out.join("");
120
+ }
121
+
122
+ // ── 单源检查 ────────────────────────────────────────────────────────────────
123
+ export function lintClientSource(src) {
124
+ const masked = stripLiterals(src);
125
+ const origLines = src.split("\n");
126
+ const maskedLines = masked.split("\n");
127
+ const violations = [];
128
+ for (let i = 0; i < maskedLines.length; i++) {
129
+ const prev = i > 0 ? origLines[i - 1] : "";
130
+ if (origLines[i].includes("check-client:allow") || prev.includes("check-client:allow")) continue;
131
+ for (const { level, re, why } of RULES) {
132
+ if (re.test(maskedLines[i])) {
133
+ violations.push({ line: i + 1, level, rule: why, text: origLines[i].trim().slice(0, 90) });
134
+ }
135
+ }
136
+ }
137
+ return violations;
138
+ }
139
+
140
+ // ── 文件/目录检查 ───────────────────────────────────────────────────────────
141
+ const SKIP_DIR = new Set(["node_modules", ".git"]);
142
+ /** 超过此体积视为 vendored bundle(如 Monaco ts.worker,合法含 interface 字样),不查。 */
143
+ const MAX_BYTES = 512 * 1024;
144
+ const isClientFile = (name) => /\.(mjs|js|cjs)$/.test(name) && !/\.min\./.test(name) && !/\.test\./.test(name);
145
+
146
+ export function lintPath(p, out = { files: 0, violations: [] }) {
147
+ const st = statSync(p);
148
+ if (st.isDirectory()) {
149
+ for (const name of readdirSync(p)) {
150
+ if (SKIP_DIR.has(name)) continue;
151
+ lintPath(join(p, name), out);
152
+ }
153
+ return out;
154
+ }
155
+ const base = p.split("/").pop();
156
+ if (!isClientFile(base)) return out;
157
+ if (st.size > MAX_BYTES) return out; // vendored bundle 豁免
158
+ out.files++;
159
+ for (const v of lintClientSource(readFileSync(p, "utf8"))) out.violations.push({ file: p, ...v });
160
+ return out;
161
+ }
162
+
163
+ export function formatViolations(violations) {
164
+ return violations
165
+ .map((v) => ` ${v.level} ${v.file}:${v.line} [${v.rule}] ${v.text}`)
166
+ .join("\n");
167
+ }
168
+
169
+ /** deploy 脚本用:检查目录,FAIL 抛错(消息含违规清单),warn 只打日志不阻断。 */
170
+ export function checkClientDir(dir, { log = console.log } = {}) {
171
+ const { files, violations } = lintPath(resolve(dir));
172
+ const fails = violations.filter((v) => v.level === "FAIL");
173
+ const warns = violations.filter((v) => v.level === "warn");
174
+ if (warns.length) log(`[check-client] ${warns.length} 处疑似 TS 残留(不阻断,建议人工确认):\n${formatViolations(warns)}`);
175
+ if (fails.length) {
176
+ throw new Error(
177
+ `client 检查未通过:${files} 个文件中 ${fails.length} 处 TS 残留(.mjs 里是合法 JS,语法检查不报,运行时必炸)。\n` +
178
+ formatViolations(fails) +
179
+ `\n修复或在该行加注释 // check-client:allow 豁免。详见 docs/aiti-ext-client-hardening-plan.md`,
180
+ );
181
+ }
182
+ if (files) log(`[check-client] ${files} 个 client 文件检查通过`);
183
+ return { files, fails: fails.length, warns: warns.length };
184
+ }
185
+
186
+ /**
187
+ * extman 安装用:扫整个扩展目录(含第三方,不遵循 client/ 约定)。
188
+ * 在 SKIP_DIR 之上额外跳过构建产物/vendored 目录(Monaco ts.worker、语言包等
189
+ * 合法包含 TS 字样,词法器对 minified 代码不提供保证);体积上限同上。
190
+ */
191
+ export function lintExtensionDir(dir) {
192
+ const out = { files: 0, violations: [] };
193
+ const walk = (p) => {
194
+ const st = statSync(p);
195
+ if (st.isDirectory()) {
196
+ for (const name of readdirSync(p)) {
197
+ if (SKIP_DIR.has(name) || VENDORED_DIRS.has(name)) continue;
198
+ walk(join(p, name));
199
+ }
200
+ return;
201
+ }
202
+ const base = p.split("/").pop();
203
+ if (!isClientFile(base) || st.size > MAX_BYTES) return;
204
+ out.files++;
205
+ for (const v of lintClientSource(readFileSync(p, "utf8"))) out.violations.push({ file: p, ...v });
206
+ };
207
+ walk(resolve(dir));
208
+ return out;
209
+ }
210
+ const VENDORED_DIRS = new Set(["vendor", "monaco", "dist", "build", "assets", "static", "bundles", "chunks"]);
211
+
212
+ // ── CLI ─────────────────────────────────────────────────────────────────────
213
+ function selftest() {
214
+ const MUST_FAIL = [
215
+ "const [st, setSt] = useState<any>(null);",
216
+ "const r = useRef<number>(null);",
217
+ "const x = v as any;",
218
+ "function f(a: unknown) {}",
219
+ 'import type { Foo } from "./x";',
220
+ "interface Foo { a: string }",
221
+ "enum Color { Red }",
222
+ "const y = v as const;",
223
+ ];
224
+ const MUST_WARN = [
225
+ "const opts = { mode: string };",
226
+ "x satisfies Y;",
227
+ "const m: Record<string, any> = {};",
228
+ ];
229
+ const MUST_PASS = [
230
+ 'const REACT_TYPE_STUB = `\ndeclare module "react" {\n export function useState<T>(init: T): [T];\n export const Fragment: any;\n}\n`;',
231
+ "/** @return {{chars1: string, chars2: string}} */",
232
+ "const [s] = useState(null);",
233
+ "useState<any>(null); // check-client:allow",
234
+ "// check-client:allow\nuseState<never>(0);",
235
+ "const cmp = a < b && c > (d);",
236
+ "if (a // 注释里 as any 不算\n ) run();",
237
+ ];
238
+ let bad = 0;
239
+ const check = (src, expect, desc) => {
240
+ const v = lintClientSource(src);
241
+ const fails = v.filter((x) => x.level === "FAIL");
242
+ const ok = expect === "FAIL" ? fails.length > 0 : expect === "PASS" ? v.length === 0 : v.length > 0 && fails.length === 0;
243
+ if (!ok) {
244
+ bad++;
245
+ console.log(`SELFTEST FAIL [${desc}] 期望 ${expect},实际 ${JSON.stringify(v)}`);
246
+ }
247
+ };
248
+ MUST_FAIL.forEach((s) => check(s, "FAIL", s.slice(0, 40)));
249
+ MUST_WARN.forEach((s) => check(s, "WARN", s.slice(0, 40)));
250
+ MUST_PASS.forEach((s) => check(s, "PASS", s.replace(/\n/g, "⏎").slice(0, 40)));
251
+ if (bad) {
252
+ console.log(`selftest: ${bad} 处未过`);
253
+ process.exit(1);
254
+ }
255
+ console.log("selftest: 全部通过(8 FAIL / 3 WARN / 7 PASS)");
256
+ }
257
+
258
+ if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
259
+ if (process.argv.includes("--selftest")) {
260
+ selftest();
261
+ } else if (process.argv.length > 2) {
262
+ const all = { files: 0, violations: [] };
263
+ for (const p of process.argv.slice(2)) lintPath(resolve(p), all);
264
+ const fails = all.violations.filter((v) => v.level === "FAIL");
265
+ const warns = all.violations.filter((v) => v.level === "warn");
266
+ if (all.violations.length) console.log(formatViolations(all.violations));
267
+ console.log(`\n${all.files} 个文件:FAIL=${fails.length} warn=${warns.length}`);
268
+ process.exit(fails.length ? 1 : 0);
269
+ } else {
270
+ console.log("用法:node check-client.mjs <file|dir>... | --selftest");
271
+ process.exit(2);
272
+ }
273
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * aiti-mail client —— 管理弹窗(原生 mjs,宿主 h()/useState 委托)
3
+ *
4
+ * 插槽:toolbar(✉ 线图按钮入口,静态条目两段式——ui.toolbar id 与本注册 raw id 一致)
5
+ * + dialogs(面板,data-ext-float 规范:拖拽/缩放/持久化/小屏全屏由 aiti-float 接管)。
6
+ * 两个页签:「配置」(SMTP 表单 + 发送测试)与「发送记录」(sqlite 记录表格)。
7
+ * 右上角 AI 按钮(🤖)由框架在 dialog 渲染层统一注入——扩展不手写。
8
+ */
9
+ let _ctx = null;
10
+ function __setCtx(ctx) { _ctx = ctx; }
11
+ function c(name) { return _ctx?.[name] ?? function () { return undefined; }; }
12
+ function h(type, props) {
13
+ var args = Array.prototype.slice.call(arguments);
14
+ return c("h").apply(null, args);
15
+ }
16
+ function useState(init) { return c("useState")(init); }
17
+ function useEffect(fn, deps) { return c("useEffect")(fn, deps); }
18
+ function useRef(v) { return c("useRef")(v); }
19
+ function apiUrl(p) {
20
+ var fn = _ctx?.apiUrl;
21
+ return typeof fn === "function" ? fn(p) : p;
22
+ }
23
+ var API = function (p) { return apiUrl("/api/extensions/aiti-mail" + p); };
24
+
25
+ // ── dock v3(P3 批):显隐收口 aiti-float 展示状态机(手写 mjs 免构建,内联薄桥) ──
26
+ var MAIL_KEY = "aiti-mail:panel";
27
+ var mailLocalOpen = false; // 未装 aiti-float 的本地降级开关
28
+ var mailFloat = function () {
29
+ var a = (typeof window !== "undefined") && window.__piweFloat;
30
+ return a && typeof a.present === "function" ? a : null;
31
+ };
32
+ var mailPresented = function () {
33
+ var a = mailFloat();
34
+ return a ? (a.presented(MAIL_KEY) || "closed") : (mailLocalOpen ? "floating" : "closed");
35
+ };
36
+ var mailSetOpen = function (v) {
37
+ var a = mailFloat();
38
+ if (a) { a.present(MAIL_KEY, v ? "open" : "close"); return; }
39
+ mailLocalOpen = v;
40
+ };
41
+ try { var mk = "pi-web:ext-float:dockpref:v1:" + MAIL_KEY; if (!localStorage.getItem(mk)) localStorage.setItem(mk, "right-panel"); } catch (e) {}
42
+ var mailFragment = (typeof Symbol !== "undefined" && Symbol.for) ? Symbol.for("react.fragment") : 0xacb;
43
+
44
+ // ── 线图(lucide 同风格,24×24 stroke) ──
45
+ var ICONS = {
46
+ mail: ["M4 6h16a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z", "m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"],
47
+ x: ["M18 6 6 18", "m6 6 12 12"],
48
+ };
49
+ function Ic(size, paths) {
50
+ return h("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor",
51
+ strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
52
+ paths.map(function (d, i) { return h("path", { key: i, d: d }); }));
53
+ }
54
+
55
+ // ── 小工具 ──
56
+ var btnStyle = function (bg, color) {
57
+ return { padding: "4px 12px", borderRadius: 6, fontSize: 12, cursor: "pointer",
58
+ background: bg, border: "1px solid var(--border)", color: color };
59
+ };
60
+ var inputStyle = { padding: "5px 8px", borderRadius: 6, fontSize: 12.5, border: "1px solid var(--border)",
61
+ background: "var(--bg)", color: "var(--text)", width: "100%", boxSizing: "border-box" };
62
+ var tdStyle = { padding: "4px 8px", borderBottom: "1px solid var(--border)", verticalAlign: "top", fontSize: 11.5 };
63
+
64
+ // ── 打开句柄(toolbar onClick 在组件外触发;状态机收口) ──
65
+ function requestOpen() { mailSetOpen(true); }
66
+
67
+ function MailPanel() { return renderMailPanel("float"); }
68
+ function MailDockFace() { return renderMailPanel("dock"); }
69
+ function renderMailPanel(face) {
70
+ // 机器态 → 本组件同步:force-update 计数器(状态机 CHANGE_EVENT 驱动,无轮询);
71
+ // 机器态在渲染时直读(useState 存值会过期)
72
+ var fuS = useState(0); var fuSet = fuS[1];
73
+ useEffect(function () {
74
+ var sync = function () { fuSet(function (n) { return n + 1; }); };
75
+ window.addEventListener("piwe-float-present-change", sync);
76
+ return function () { window.removeEventListener("piwe-float-present-change", sync); };
77
+ }, []);
78
+ var st = mailPresented();
79
+ var open = face === "float" ? st === "floating" : st === "docked:right-panel";
80
+ var floating = face === "float";
81
+ var setOpen = function (v) { mailSetOpen(v); };
82
+ var tabS = useState("config"); var tab = tabS[0]; var setTab = tabS[1];
83
+ var cfgS = useState(null); var cfg = cfgS[0]; var setCfg = cfgS[1];
84
+ var formS = useState({ host: "", port: "465", secure: "starttls", user: "", pass: "", from: "", testTo: "" });
85
+ var form = formS[0]; var setForm = formS[1];
86
+ var logS = useState(null); var log = logS[0]; var setLog = logS[1];
87
+ var msgS = useState(null); var msg = msgS[0]; var setMsg = msgS[1];
88
+ var busyS = useState(false); var busy = busyS[0]; var setBusy = busyS[1];
89
+ var liveRef = useRef(true);
90
+
91
+ var setF = function (k, v) { setForm(function (f0) { var f = Object.assign({}, f0); f[k] = v; return f; }); };
92
+
93
+ var loadConfig = function () {
94
+ return fetch(API("/config")).then(function (r) { return r.json(); }).then(function (d) {
95
+ if (!liveRef.current) return;
96
+ setCfg(d.config || null);
97
+ if (d.config) {
98
+ setForm(function (f0) {
99
+ var f = Object.assign({}, f0);
100
+ f.host = d.config.host || "";
101
+ f.port = String(d.config.port || 465);
102
+ f.secure = d.config.secure ? "ssl" : (d.config.noStarttls ? "none" : "starttls");
103
+ f.user = d.config.user || "";
104
+ f.from = d.config.from || "";
105
+ return f;
106
+ });
107
+ }
108
+ }).catch(function () {});
109
+ };
110
+ var loadLog = function () {
111
+ fetch(API("/log?limit=100")).then(function (r) { return r.json(); })
112
+ .then(function (d) { if (liveRef.current) setLog(d.sends || []); })
113
+ .catch(function () { if (liveRef.current) setLog([]); });
114
+ };
115
+ useEffect(function () {
116
+ liveRef.current = true;
117
+ if (open) { loadConfig(); loadLog(); }
118
+ return function () { liveRef.current = false; };
119
+ }, [open]);
120
+
121
+ var save = function () {
122
+ setBusy(true); setMsg(null);
123
+ var body = { host: form.host, port: Number(form.port) || (form.secure === "ssl" ? 465 : 25),
124
+ secure: form.secure === "ssl", noStarttls: form.secure === "none",
125
+ user: form.user, from: form.from };
126
+ if (form.pass) body.pass = form.pass; // 缺省=保留旧值(服务端语义)
127
+ fetch(API("/config"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
128
+ .then(function (r) { return r.json(); })
129
+ .then(function (d) {
130
+ if (!liveRef.current) return;
131
+ setMsg(d.ok ? { kind: "ok", text: "已保存" } : { kind: "err", text: "保存失败:" + (d.error || "?") });
132
+ if (d.ok) loadConfig();
133
+ })
134
+ .catch(function (e) { if (liveRef.current) setMsg({ kind: "err", text: "保存失败:" + e }); })
135
+ .finally(function () { if (liveRef.current) setBusy(false); });
136
+ };
137
+ var sendTest = function () {
138
+ setBusy(true); setMsg(null);
139
+ fetch(API("/test"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ to: form.testTo }) })
140
+ .then(function (r) { return r.json(); })
141
+ .then(function (d) {
142
+ if (!liveRef.current) return;
143
+ var rs = d.results || [];
144
+ var bad = rs.filter(function (r) { return r.status !== "ok"; });
145
+ if (d.ok) setMsg({ kind: "ok", text: "测试邮件已发送 → " + rs.map(function (r) { return r.to; }).join(", ") + "(" + rs.map(function (r) { return r.durationMs; }).join("/") + "ms)" });
146
+ else setMsg({ kind: "err", text: "发送失败:" + (d.error || (bad[0] && bad[0].error) || "?") });
147
+ loadLog();
148
+ })
149
+ .catch(function (e) { if (liveRef.current) setMsg({ kind: "err", text: "发送失败:" + e }); })
150
+ .finally(function () { if (liveRef.current) setBusy(false); });
151
+ };
152
+
153
+ if (!open) return null;
154
+
155
+ var row = function (label, el) {
156
+ return h("label", { key: label, style: { display: "grid", gridTemplateColumns: "72px 1fr", gap: 8, alignItems: "center", fontSize: 12 } },
157
+ h("span", { style: { color: "var(--text-muted)" } }, label), el);
158
+ };
159
+ var dlgRoot = {
160
+ position: "fixed", right: 18, bottom: 70, width: 620, maxWidth: "94vw", maxHeight: "80vh",
161
+ display: "flex", flexDirection: "column", zIndex: 2147483101,
162
+ background: "var(--bg,#fff)", color: "var(--text,#1a1a1a)",
163
+ border: "1px solid var(--border,#e0e0e0)", borderRadius: 16,
164
+ boxShadow: "0 16px 64px rgba(0,0,0,.25)", fontFamily: "inherit", overflow: "hidden",
165
+ };
166
+ var head = { display: "flex", alignItems: "center", gap: 8, padding: "10px 14px",
167
+ borderBottom: "1px solid var(--border,#e0e0e0)", background: "var(--bg-subtle,#f5f5f5)", flexShrink: 0 };
168
+ var tabBtn = function (id, label) {
169
+ return h("button", { key: id, onClick: function () { setTab(id); setMsg(null); if (id === "log") loadLog(); },
170
+ style: { padding: "3px 12px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: "1px solid " + (tab === id ? "var(--border)" : "transparent"),
171
+ background: tab === id ? "var(--bg)" : "transparent", color: tab === id ? "var(--text)" : "var(--text-muted)",
172
+ fontWeight: tab === id ? 600 : 400 } }, label);
173
+ };
174
+
175
+ var bodyEl;
176
+ if (tab === "config") {
177
+ var hasCfg = !!cfg;
178
+ bodyEl = h("div", { style: { display: "flex", flexDirection: "column", gap: 8 } },
179
+ row("SMTP 服务器", h("input", { value: form.host, onChange: function (e) { setF("host", e.target.value); }, placeholder: "如 smtp.qq.com", style: inputStyle })),
180
+ h("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 } },
181
+ row("端口", h("input", { value: form.port, onChange: function (e) { setF("port", e.target.value.replace(/\D/g, "")); }, placeholder: "465 / 587 / 25", style: inputStyle })),
182
+ row("加密", h("select", { value: form.secure, onChange: function (e) { setF("secure", e.target.value); }, style: inputStyle },
183
+ h("option", { value: "ssl" }, "SSL/TLS(465 隐式)"),
184
+ h("option", { value: "starttls" }, "STARTTLS(587 推荐,服务器支持则自动升级)"),
185
+ h("option", { value: "none" }, "不加密直连(25 等,永不升级)")))),
186
+ row("用户名", h("input", { value: form.user, onChange: function (e) { setF("user", e.target.value); }, placeholder: " usually 即发件邮箱", style: inputStyle })),
187
+ row("密码/授权码", h("input", { type: "password", value: form.pass, onChange: function (e) { setF("pass", e.target.value); },
188
+ placeholder: hasCfg && cfg && cfg.hasPass ? "已保存(留空=不修改)" : "SMTP 授权码(非登录密码)", style: inputStyle })),
189
+ row("发件人", h("input", { value: form.from, onChange: function (e) { setF("from", e.target.value); }, placeholder: "可选,缺省用用户名", style: inputStyle })),
190
+ h("div", { style: { display: "flex", justifyContent: "flex-end" } },
191
+ h("button", { disabled: busy || !form.host.trim(), onClick: save,
192
+ style: Object.assign(btnStyle("#3b82f6", "#fff"), { opacity: busy || !form.host.trim() ? 0.5 : 1 }) }, busy ? "保存中…" : "保存配置")),
193
+ h("div", { style: { borderTop: "1px solid var(--border)", paddingTop: 8, display: "flex", flexDirection: "column", gap: 8 } },
194
+ h("div", { style: { fontSize: 12, fontWeight: 600 } }, "发送测试"),
195
+ row("测试收件人", h("input", { value: form.testTo, onChange: function (e) { setF("testTo", e.target.value); },
196
+ placeholder: "you@example.com", style: inputStyle })),
197
+ h("div", { style: { display: "flex", justifyContent: "flex-end" } },
198
+ h("button", { disabled: busy || !form.testTo.trim() || !cfg, onClick: sendTest,
199
+ style: Object.assign(btnStyle("var(--bg-hover)", "var(--text)"), { opacity: busy || !form.testTo.trim() || !cfg ? 0.5 : 1 }),
200
+ title: !cfg ? "先保存配置" : "" }, "发送测试")),
201
+ h("div", { style: { fontSize: 11, color: "var(--text-muted)" } },
202
+ "提示:QQ/163 邮箱需在邮箱设置里开启 SMTP 并使用「授权码」。密码只存本机 configDir/config.json(0600),API 不回显。")));
203
+ } else {
204
+ bodyEl = h("div", null,
205
+ h("div", { style: { display: "flex", justifyContent: "flex-end", marginBottom: 6 } },
206
+ h("button", { onClick: loadLog, style: btnStyle("var(--bg-hover)", "var(--text)") }, "刷新")),
207
+ log === null
208
+ ? h("div", { style: { color: "var(--text-muted)", fontSize: 12 } }, "加载中…")
209
+ : log.length === 0
210
+ ? h("div", { style: { color: "var(--text-muted)", fontSize: 12, padding: "20px 0", textAlign: "center" } }, "暂无发送记录")
211
+ : h("table", { style: { width: "100%", borderCollapse: "collapse", fontSize: 11.5 } },
212
+ h("thead", null, h("tr", null, ["时间", "收件人", "主题", "状态", "耗时", "来源"].map(function (th) {
213
+ return h("th", { key: th, style: { textAlign: "left", padding: "4px 8px", borderBottom: "1px solid var(--border)", color: "var(--text-muted)" } }, th);
214
+ }))),
215
+ h("tbody", null, log.map(function (r) {
216
+ return h("tr", { key: r.id },
217
+ h("td", { style: tdStyle, title: r.ts }, new Date(r.ts).toLocaleString()),
218
+ h("td", { style: tdStyle }, r.to_addr),
219
+ h("td", { style: Object.assign({}, tdStyle, { maxWidth: 200, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }), title: r.subject }, r.subject),
220
+ h("td", { style: Object.assign({}, tdStyle, { color: r.status === "ok" ? "var(--ok, #10b981)" : "var(--danger, #ef4444)" }), title: r.error || "" }, r.status === "ok" ? "ok" : "error"),
221
+ h("td", { style: tdStyle }, r.duration_ms != null ? r.duration_ms + "ms" : ""),
222
+ h("td", { style: tdStyle }, r.source || ""));
223
+ }))),
224
+ h("div", { style: { fontSize: 11, color: "var(--text-muted)", marginTop: 8 } }, "记录保留 90 天(cache/mails.db)。"));
225
+ }
226
+
227
+ var panelEl = h(mailFragment, null,
228
+ h("div", { style: head },
229
+ floating ? h("span", { style: { fontWeight: 700, fontSize: 14, display: "flex", alignItems: "center", gap: 6 } },
230
+ Ic(15, ICONS.mail), "邮件") : null,
231
+ h("span", { style: { flex: 1 } }),
232
+ tabBtn("config", "配置"),
233
+ tabBtn("log", "发送记录"),
234
+ floating ? h("button", { onClick: function () { setOpen(false); }, "aria-label": "关闭", title: "关闭",
235
+ style: { background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", padding: "2px 6px", flexShrink: 0 } },
236
+ Ic(12, ICONS.x)) : null),
237
+ h("div", { style: { flex: 1, minHeight: 0, overflowY: "auto", padding: 12 } },
238
+ msg && h("div", { role: "alert",
239
+ style: { marginBottom: 8, padding: "6px 10px", borderRadius: 6, fontSize: 12,
240
+ background: msg.kind === "ok" ? "color-mix(in srgb, var(--ok, #10b981) 12%, transparent)" : "color-mix(in srgb, var(--danger, #ef4444) 12%, transparent)",
241
+ color: msg.kind === "ok" ? "var(--ok, #10b981)" : "var(--danger, #ef4444)" } }, msg.text),
242
+ bodyEl));
243
+ // dock v3 分面:float = 浮窗壳(float 契约);dock = 右栏 tab(embedded 铺满,TabBar ✕ 关闭)
244
+ if (floating) {
245
+ return h("div", { "data-ext-float": "1", "data-ext-float-key": MAIL_KEY, "data-ext-float-dock": "right-panel",
246
+ "data-ext-float-dock-default": "right-panel",
247
+ "data-ext-float-w": 620, "data-ext-float-h": 540, "data-ext-float-full": "1", style: dlgRoot }, panelEl);
248
+ }
249
+ return h("div", { style: { width: "100%", height: "100%", display: "flex", flexDirection: "column", background: "var(--bg,#fff)", color: "var(--text,#1a1a1a)", overflow: "hidden" } }, panelEl);
250
+ }
251
+
252
+ function register(ctx) {
253
+ __setCtx(ctx);
254
+ ctx.registerItems("toolbar", [{
255
+ id: "aiti-mail-open",
256
+ meta: {
257
+ label: "邮件",
258
+ title: "邮件:SMTP 配置 / 发送测试 / 发送记录",
259
+ icon: "icons/mail.svg",
260
+ onClick: function () { requestOpen(); },
261
+ },
262
+ }]);
263
+ ctx.registerItems("dialogs", [{ id: "aiti-mail-panel", render: MailPanel }]);
264
+ // aiti-float dock v3(P3 批):右栏「邮件」tab(id 拼后 = MAIL_KEY)
265
+ ctx.registerItems("rightFileDock", [{ id: "panel", render: MailDockFace, meta: { title: "邮件" } }]);
266
+ try { console.log("[aiti-mail] 管理弹窗已挂载(toolbar ✉ 入口 → dialogs 弹窗)"); } catch (e) { /* noop */ }
267
+ }
268
+
269
+ export { register as default };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * deploy 脚本共享「部署状态 commit」助手(pi-web-ext 各扩展 tools/deploy.mjs 统一用)。
3
+ *
4
+ * 背景(2026-08-30 排障,session 0cc6ad47 op264):重复部署同内容时
5
+ * `git add -A && git commit` 以 "nothing to commit" exit 1,而 stdio:"ignore" 吞掉
6
+ * stderr,输出「提交失败(忽略):Command failed: …」假报警(msg-smart 实证);
7
+ * 另有 --allow-empty 族每次部署都造空 commit 噪音。本助手统一三态语义:
8
+ * committed → 有暂存变更,已提交
9
+ * clean → 无变更,跳过(不算失败)
10
+ * failed → git 真失败(带 stderr 首行,不阻断部署——部署本体永远优先)
11
+ *
12
+ * 用法:
13
+ * import { deployCommit } from "../../_shared/deploy-commit.mjs";
14
+ * deployCommit({ repo: extRepo, name: "aiti-msg-smart", paths: ["aiti-msg-smart"],
15
+ * log: (m) => console.log("[deploy] " + m) });
16
+ *
17
+ * 红线:本助手永不 throw、不触碰部署产物路径之外的任何东西;只对 <repo> 做
18
+ * add/diff/commit(默认 --no-verify,extRepo 无 hooks,防未来误挂)。
19
+ */
20
+ import { execFileSync } from "node:child_process";
21
+
22
+ export function deployCommit({ repo, name, paths = null, extraMsg = "", ident = false, log = console.log }) {
23
+ const git = (args) =>
24
+ execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
25
+ const firstLine = (s) => String(s || "").trim().split("\n")[0];
26
+ try {
27
+ // 暂存:默认只收本扩展子目录(与 add <ext> 行为对齐;避免把别的扩展的
28
+ // 未提交改动卷进本扩展的部署 commit);paths=null 时退回 add -A 全仓。
29
+ git(["add", ...(paths && paths.length ? ["--", ...paths] : ["-A"])]);
30
+ let dirty;
31
+ try {
32
+ git(["diff", "--cached", "--quiet"]);
33
+ dirty = false; // exit 0 = 暂存区与 HEAD 一致
34
+ } catch (e) {
35
+ if (e.status !== 1) throw e; // 1 = 有变更;其他才是真错误
36
+ dirty = true;
37
+ }
38
+ if (!dirty) {
39
+ log("扩展仓库无变化(跳过提交)");
40
+ return { status: "clean" };
41
+ }
42
+ const stamp = new Date().toISOString().replace("T", " ").slice(0, 19);
43
+ const msg = `[deploy] ${name} · ${stamp}${extraMsg ? " · " + extraMsg : ""}`;
44
+ git([...(ident ? ["-c", "user.name=deploy", "-c", "user.email=deploy@local"] : []), "commit", "--no-verify", "-m", msg]);
45
+ log("扩展仓库已提交:" + msg);
46
+ return { status: "committed", msg };
47
+ } catch (e) {
48
+ const reason = firstLine(e.stderr) || firstLine(e.message) || "git error";
49
+ log("提交失败(忽略,不影响部署):" + reason);
50
+ return { status: "failed", reason };
51
+ }
52
+ }