合并 feat/keycloak-auth:统一鉴权中心后端(ADR-012)+ 邮件收发模块(ADR-013)+ CI/CD 入库
CI / ci (push) Failing after 10s

主支这边(f01286a)是前端接 Keycloak(keycloak-js / PKCE / Bearer / 60 秒票据)、
活动模块运营态模版、数据域与工作台改版、/me 个人中心;分支这边 66 条:后端资源服务器
与角色→端、邮件收发(IMAP/SMTP · 企业微信邮件 API)、CI/CD 四件与两份文档、
pnpm-lock 补 mail 模块 importer。

冲突两处(packages/contracts/src/org.ts · api/workbench.ts):前端对着后端文档先行补的
principalSchema / meRoute / authTicketRoute(注释里自己写着「apps/api 仍是旧后端,契约暂留」)
缺 RouteContract 必填的 access,SURFACES/surfaceSchema 也已在 api/http.ts —— 取分支(后端)
这一份;前端代码对它原样编译通过(staff.email / roles 本就在 staffSchema 里)。
pnpm-lock 自动合并,--frozen-lockfile 核对一致。

五道门禁绿:1945 测试通过、23 跳过(无模型密钥 / 无真信箱的惯常项);
合并树与先前验证过的那棵树(9e9ba38)逐字节一致。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01712YeSo5fTfkCSPZHBiniq
This commit is contained in:
xcs
2026-08-26 14:34:19 +08:00
co-authored by Claude Fable 5
166 changed files with 29829 additions and 1610 deletions
+320
View File
@@ -0,0 +1,320 @@
# JCAI-WOS CI —— 五道门禁 + 从空库起的集成测试 + 生产构建
#
# 只在主干跑:push 到 mvp-foundation(PR 合并产生的那一推同样算)。
# runner 是 host 模式,label `ubuntu-latest`;宿主机自带 node22 / pnpm10 / docker。
# 门禁一律以退出码验(CLAUDE.md 铁规矩 1),不以 echo 验。
name: CI
on:
push:
branches: [mvp-foundation]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
# 国内镜像源:pnpm 认 npm 的 env 形式
NPM_CONFIG_REGISTRY: https://registry.npmmirror.com
COREPACK_NPM_REGISTRY: https://registry.npmmirror.com
# NODE_ENV 一律不设 production —— secret-box.ts 在 production 下拿不到
# JCAI_SECRET_KEY 会直接抛;CI 要的是 dev 兜底那条路。
jobs:
ci:
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
# ── 取代码:纯 git,一个第三方 action 都不依赖 ─────────────────────
- name: 取代码
env:
T1: ${{ secrets.GITEA_TOKEN }}
T2: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
cd "$GITHUB_WORKSPACE"
git init -q .
git config --global --add safe.directory "$GITHUB_WORKSPACE"
URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git remote set-url origin "$URL" 2>/dev/null || git remote add origin "$URL"
TOKEN="${T1:-${T2:-}}"
HEADER="http.${GITHUB_SERVER_URL}/.extraheader"
if [ -n "$TOKEN" ]; then
AUTH="$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')"
git config --local "$HEADER" "Authorization: basic $AUTH"
fi
fetch() { git fetch --depth=1 --no-tags origin "+${1}:refs/ci/head"; }
# 两档退让:先按 commit(服务端未必允许按 SHA 取),再按分支 ref
if ! fetch "$GITHUB_SHA" && ! fetch "$GITHUB_REF"; then
# 换一种带凭据的写法再试一遍(extraheader 与 token 进 URL,Gitea 各版本认的不一样)
if [ -z "$TOKEN" ]; then
echo '取代码失败:没有可用 token,匿名也拉不下来'; exit 1
fi
git config --local --unset-all "$HEADER" || true
SCHEME="${GITHUB_SERVER_URL%%://*}"
HOST="${GITHUB_SERVER_URL#*://}"
git remote set-url origin "${SCHEME}://${TOKEN}@${HOST}/${GITHUB_REPOSITORY}.git"
fetch "$GITHUB_SHA" || fetch "$GITHUB_REF"
fi
git checkout -qf refs/ci/head
# 留着 node_modules(host 模式工作区跨次复用,留着就等于一份缓存),
# 其余未跟踪物一律清掉 —— 上一跑的 dist/.build/.api-tmp 不许影响这一跑
git clean -ffdx -e node_modules
echo "HEAD=$(git rev-parse HEAD) 期望=${GITHUB_SHA}"
- name: 工具链断言
run: |
set -euo pipefail
node -v
pnpm -v
node -e 'const [a, b] = process.versions.node.split(".").map(Number);
if (a < 22 || (a === 22 && b < 6)) {
console.error("需要 Node >= 22.6 —— gen:tiers 用 --experimental-strip-types");
process.exit(1);
}'
case "$(pnpm -v)" in
10.*) ;;
*) echo "需要 pnpm 10.x(仓库钉的是 10.33.0)"; exit 1 ;;
esac
- name: 装依赖
run: pnpm install --frozen-lockfile
# ── 五道门禁,逐条一步 ────────────────────────────────────────────
- name: 门禁前置 · 生成层级表
run: pnpm gen:tiers
- name: 门禁 1/5 · 类型检查
run: pnpm typecheck
- name: 门禁 2/5 · Lint
run: pnpm lint
- name: 门禁 3/5 · 依赖门禁
run: pnpm depcruise
- name: 门禁 4/5 · 契约快照
run: pnpm api:check
# api:check 自己那道 git 比对是**会失败开口**的(git 一抛错就印「非 git 仓库,
# 跳过比对」然后 exit 0)。这一步自己再 status 一遍,吞不掉;
# 顺带管住 gen:tiers 的入库产物 tools/depcruise-tiers.generated.cjs。
# 位置在测试之前 —— 测试期的 seed 可能往 .data/ 落文件,那不算漂移。
- name: 生成物无漂移
run: |
set -euo pipefail
DIRTY="$(git status --porcelain)"
if [ -n "$DIRTY" ]; then
echo "$DIRTY"
echo '---'
git --no-pager diff --stat
echo '门禁跑完工作树有改动:入库的生成物与现生成的不一致。'
echo '本地跑 pnpm gates,把 api-report/ 与 tools/depcruise-tiers.generated.cjs 一并提交。'
exit 1
fi
echo '工作树干净 ✔'
# ── 库:一次性,跑完就拆 ──────────────────────────────────────────
- name: 建本次跑的一次性库
id: db
env:
CI_PG_ADMIN_URL: ${{ secrets.CI_PG_ADMIN_URL }}
run: node tools/ci.mjs db-create "jcai_ci_${GITHUB_RUN_ID}"
- name: 从空库跑完整条迁移链
run: pnpm db:migrate
- name: 库真的连得上
run: node tools/ci.mjs db-probe
- name: 门禁 5/5 · 单元与集成测试
run: |
set -euo pipefail
pnpm test --reporter=default --reporter=json \
--outputFile.json="${RUNNER_TEMP:-/tmp}/vitest.json"
node tools/ci.mjs assert-it "${RUNNER_TEMP:-/tmp}/vitest.json"
# 用 steps.db.outcome 而不是裸 always():装依赖就失败的那种跑法里
# 根本没有库可拆,裸 always() 只会多吐一条看着吓人的假失败。
- name: 拆掉一次性库
if: ${{ always() && steps.db.outcome == 'success' }}
env:
CI_PG_ADMIN_URL: ${{ secrets.CI_PG_ADMIN_URL }}
run: node tools/ci.mjs db-drop "jcai_ci_${GITHUB_RUN_ID}"
# ── 生产构建 ────────────────────────────────────────────────────
- name: 生产构建 · api
run: pnpm --filter @app/api build
- name: 生产构建 · web
run: pnpm --filter @app/web build
- name: 生产构建 · 活动包
run: pnpm activities:build
# ══ CD ══════════════════════════════════════════════════════════
# 门禁全绿之后才走到这儿(同一个 job,前面任一步红后面自然不跑)。
# 机器相关的东西一律从 vars.* 读,不写死;机密从 secrets.* 读。
- name: 部署 · 前置断言
env:
DEPLOY_DIR: ${{ vars.DEPLOY_DIR }}
PM2_APP: ${{ vars.PM2_APP }}
WEB_ROOT: ${{ vars.WEB_ROOT }}
PG_CONTAINER: ${{ vars.PG_CONTAINER }}
PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }}
JCAI_SEED_PASSWORD: ${{ secrets.JCAI_SEED_PASSWORD }}
JCAI_DEEPSEEK_API_KEY: ${{ secrets.JCAI_DEEPSEEK_API_KEY }}
run: node tools/cd.mjs preflight
# 迁移没有 down,备份是唯一的回滚手段 —— 所以它在换代码之前,且备不成就红
- name: 部署 · 备份生产库
env:
DEPLOY_DIR: ${{ vars.DEPLOY_DIR }}
PG_CONTAINER: ${{ vars.PG_CONTAINER }}
PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
run: node tools/cd.mjs backup
- name: 部署 · 确保生产库在
env:
PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
run: node tools/cd.mjs db-ensure
- name: 部署 · 发布产物
env:
DEPLOY_DIR: ${{ vars.DEPLOY_DIR }}
run: |
set -euo pipefail
REL="${DEPLOY_DIR}/releases/${GITHUB_SHA}"
rm -rf "$REL"
mkdir -p "$REL"
# 产物 + db/(migrate.js 靠向上找 db/migrations,相对位置不能变)+ 根三件
rsync -a --relative \
./apps/api/dist ./apps/api/package.json \
./apps/web/dist \
./db \
./package.json ./pnpm-lock.yaml ./pnpm-workspace.yaml \
"$REL"/
# 活动包产物(装包时要读;装完就不再需要)
rsync -a --relative ./activities/*/dist "$REL"/
# 各 workspace 包的 package.json —— pnpm 认工作区要它们全在,少一个 lockfile 就对不上
git ls-files '*/package.json' | grep -v '^spike/' > "$RUNNER_TEMP/pkgs.txt"
rsync -a --files-from="$RUNNER_TEMP/pkgs.txt" ./ "$REL"/
# 产物是 ESM 且 skipNodeModulesBundle —— 目标目录必须有自己的 node_modules
cd "$REL"
pnpm install --prod --frozen-lockfile --ignore-scripts
printf 'sha=%s\nref=%s\nrun=%s\nat=%s\n' \
"$GITHUB_SHA" "$GITHUB_REF_NAME" "$GITHUB_RUN_ID" "$(date -Iseconds)" > "$REL/RELEASE"
echo "RELEASE_DIR=$REL" >> "$GITHUB_ENV"
echo "发布目录:$REL"
# 直接跑 dist 里的入口,不走 pnpm db:migrate —— 那条会先 tsup(clean:true),
# 在正跑着的服务脚下把 dist 清掉
- name: 部署 · 迁移
env:
DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
run: cd "$RELEASE_DIR" && node apps/api/dist/migrate.js
- name: 部署 · 迁移到位断言
env:
PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
run: node tools/cd.mjs migrate-verify
# 出两个旗:FIRST_DEPLOY / ACTIVITIES_INSTALL。
# 版本没升但产物内容变了会在这里当场红(否则新代码静默不上线)。
- name: 部署 · 判首次播种与活动包升级
env:
PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
run: node tools/cd.mjs activities-plan
# 空库唯一该跑 seed 的时刻。升级时绝不跑 —— 它会把治理台配好的
# 工具连接端点打回 replay://crm、重置白名单、重置模型实例的 baseUrl/model。
- name: 部署 · 首次播种
if: ${{ env.FIRST_DEPLOY == '1' }}
env:
DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }}
JCAI_SEED_PASSWORD: ${{ secrets.JCAI_SEED_PASSWORD }}
JCAI_DEEPSEEK_API_KEY: ${{ secrets.JCAI_DEEPSEEK_API_KEY }}
run: |
set -euo pipefail
# 上面 if: 判错的危险方向是「升级时误跑 seed」—— 那会重写整套治理台配置。
# 所以这里自己再确认一次:表达式判错也伤不到生产。
[ "${FIRST_DEPLOY:-0}" = "1" ] || { echo "FIRST_DEPLOY 不是 1,不播种(这一步本不该执行到)"; exit 0; }
cd "$RELEASE_DIR"
JCAI_ACTIVITIES_DIR="$RELEASE_DIR/activities" node apps/api/dist/seed.js
- name: 部署 · 装活动包
if: ${{ env.FIRST_DEPLOY == '0' && env.ACTIVITIES_INSTALL == '1' }}
env:
DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }}
run: |
set -euo pipefail
# 同上:装包会按主键覆盖 8 行样例主数据并发出 data 事件,不该装的时候别装
[ "${ACTIVITIES_INSTALL:-0}" = "1" ] || { echo "ACTIVITIES_INSTALL 不是 1,不装(这一步本不该执行到)"; exit 0; }
cd "$RELEASE_DIR"
JCAI_ACTIVITIES_DIR="$RELEASE_DIR/activities" node apps/api/dist/install-activities.js
# install-activities 的所有失败路径都只打日志、永远 exit 0 —— 回查库才说得出实情
- name: 部署 · 活动包回查
env:
PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
run: node tools/cd.mjs activities-verify
- name: 部署 · 发前端
env:
WEB_ROOT: ${{ vars.WEB_ROOT }}
run: |
set -euo pipefail
rsync -a --delete ./apps/web/dist/ "$WEB_ROOT"/
echo "前端已发到 $WEB_ROOT($(find "$WEB_ROOT" -type f | wc -l | tr -d ' ') 个文件)"
# 用 releases 的**真实路径**起,不经 current 软链:
# main.ts 的入口守卫拿 import.meta.url 与 process.argv[1] 做字符串比较,
# 经软链启动两边不等 → boot() 永不执行 → 进程零输出 exit 0(实测过)。
# 换版本时脚本路径会变,所以是 delete+start 而不是 restart。
- name: 部署 · 重启 pm2
env:
PM2_APP: ${{ vars.PM2_APP }}
DEPLOY_DIR: ${{ vars.DEPLOY_DIR }}
APP_NODE_ENV: ${{ vars.APP_NODE_ENV }}
DEPLOY_PORT: ${{ vars.DEPLOY_PORT }}
PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }}
JCAI_SEED_PASSWORD: ${{ secrets.JCAI_SEED_PASSWORD }}
run: |
set -euo pipefail
# 有 HTTPS 才该设 production:production 会给登录 cookie 加 Secure,
# 而 Secure 的 cookie 浏览器在 HTTP 下根本不回传 —— 登录会静默失效。
export NODE_ENV="${APP_NODE_ENV:-production}"
export PORT="${DEPLOY_PORT:-8787}"
export DATABASE_URL="$PROD_DATABASE_URL"
export JCAI_ATTACHMENT_ROOT="$DEPLOY_DIR/data/attachments"
pm2 delete "$PM2_APP" >/dev/null 2>&1 || true
pm2 start "$RELEASE_DIR/apps/api/dist/main.js" --name "$PM2_APP" --cwd "$RELEASE_DIR" --update-env
pm2 list
# 探活地址交给下一步,别让它去猜端口 —— 猜错会打到别人的服务上,还判成绿
echo "HEALTH_URL=http://127.0.0.1:$PORT/api/health" >> "$GITHUB_ENV"
# 库挂了 /api/health 照样返 200,只是 body 里 db:'down' —— 所以验 body
- name: 部署 · 探活
run: node tools/cd.mjs health
- name: 部署 · 收尾
env:
DEPLOY_DIR: ${{ vars.DEPLOY_DIR }}
run: |
set -euo pipefail
ln -sfn "$RELEASE_DIR" "$DEPLOY_DIR/current"
node tools/cd.mjs prune
echo '── 这一版发出去了 ──'
cat "$RELEASE_DIR/RELEASE"
ls -1t "$DEPLOY_DIR/backups" | head -1 | sed 's/^/最近一份备份:/'
+3
View File
@@ -18,3 +18,6 @@ coverage/
# 浏览器走查的截图与日志(本地取证用,不入库)
.playwright-mcp/
/*.png
# SDD 工作区(ledger / brief / 评审包)—— 记录归 git 历史,过程件不入库
.superpowers/
+40 -4
View File
@@ -1294,6 +1294,37 @@
的 card-sent 也算隔开),一句话只领一张卡。3 测(26 → 全绿)。**同一症状两条独立的路**
(时钟偏 / 并卡误认),治完一条要把另一条也走一遍 —— 这是 08-23 灰底那课的第二次。
- **工作成果里摆着别单的资料:「这一单产出的」被算成了「这张表里所有的行」**(2026-08-25
柴浩:「这个工作成果怎么有 4 个资料,这单的需求只会产出一个」):那一单只写了
`CT-2026-0004` 一行(三笔留痕全指着它、放行合并 3 行),而工作成果里摆着四份 ——
另外三份是**品牌内容库里历次跑出来的资产**。病根一处、抄了两遍:放行存档
(`archiveAssetFiles`)与在办列单(`assetFilesOf`)取行都用 `taskView(where:{})` ——
那是「正式版全表 + 本单草稿」的合并视图,而资料常落在**主数据**上(`content.asset`),
整表里躺着别单放行过的行。台账契约本就把这两件事分得清清楚楚(`taskDrafts` 的注释:
「引擎求值要区分『这一单的事实』与『别人放行后的事实』」),视图模版也一律绑 `mine:`
——只有资料这两处忘了。修:抽一段 `assetRowsOf` 存档与列单同用,认**本单碰过的行**
(草稿键),值仍取叠加行(改过一格的行按落库后的样子造文件);`touched` 是必填入参 ——
「哪几行是这一单的」每个调用方都得回答得出来。e2e 补**份数断言**(存档份数 =
本单写下的资产行数,多一份也判失败 —— 从前只断「取得回、渲得出」,多摆几份照样全绿)。
用户那一单误存的三份已删,工作成果现摆 1 份、点开即那份 .docx。5 测。
顺手清两笔:`wake.test.ts` 的 `as unknown as`(§12.5 禁止清单,lint 红)、
`wake` 契约的 api-report 快照(上一轮没入库,api:check 一直红)。
- **统一鉴权中心(Keycloak)接入,只做后端**(2026-08-25,ADR-012):资源服务器 —— 后端只验 Bearer JWT
(`jose`,RS256 / iss / aud 本地验,不回源);`Staff.id` = `sub`,首登即建、Admin API 同步名册;
**权限只到端**:`sys.role_access` 一行一个 realm 角色,运营中心可改(锁死不变量:改完得有人还能进运营中心);
路由归属写在契约 `access` 上,app 层一处中间件统一判 401/403,治理路由里 59 处守卫整体删除,
覆盖测试 fail-closed;SSE 与下载走 60 秒票据;口令 / 会话表 / 岗位授权退役(八条路由回 410,契约留一轮)。
web 待按前端手册接入,接入前登不进。
- **邮件收发模块接上腾讯企业邮箱,两条路**(2026-08-25,ADR-013,`apps/api/src/modules/mail` L1):IMAP/SMTP(任意信箱 + 客户端专用密码)与
企业微信邮件 API(应用邮箱);腾讯没有带内容的推送,收信是**轮询拉**(每账号自己的间隔,跨进程租约 `polling_until`)→ postal-mime 解析 →
`sys.mail_inbound` 两把唯一键去重 → 分发给订阅方(`channel` 是第一个:来信归一进事件流,活动经触发器 / `listen` 收到)。
分发**自动只投一次**(channel → event 按 cause.ref 不幂等,重投会起两单),失败在运营面可见,「重新投递」是人按的钮。
发信:`mail.send` 工具 2.0.0 真发(放行时经 ToolInvoker;`dryRun` 只校验 —— 注册的试运行会真调处理器)、channel 出站经 `mail.send`、
运营面试发;先落 `sending` 再发,厂商失败不抛(`state:'failed'` + 原话脱敏);带幂等键的不发两封。凭证密封,视图只回 `secretBound`。
15 条 `/api/gov/mail/*` 路由**全部归运营中心 `ops`**(读写都不给设计端 —— 邮箱那一屏没有设计端消费方,入站正文是客户来往内容,
见 ADR-013 决策 10);前端未做,照 `docs/前端对接说明-邮件收发.md`。新依赖 imapflow / nodemailer@8 / postal-mime。
欠账(debt):event 按 cause.ref 幂等、IDLE、wecom 无 Message-ID、channel 无 upsert 路由(共十五笔见 debt.md)。
## 文档权威地图
| 层 | 文件 | 说明 |
@@ -1302,6 +1333,10 @@
| | `docs/产品设计.md` | 三面一体的产品形态与决策记录 |
| | `docs/活动开发规范.md` | **开发活动照这份**(2026-08-02 第三版,按四轴重组:解耦机制/Harness 契约/Loop/活动怎么写):基座读数与机检纪律、门与闸、五类卡、剧本 steps、建模十纪律、三常备案例与验收口径(released+被拒0)、**排障速查表**;§8 = 待实现 |
| | `docs/HTML报告生成规范.md` | **报告模版照这份**(2026-08-02 从开发规范拆出):一份 HTML 三个身份(件/表单/归档件)、**报告与卡的关系与同源纪律**、绑定语言全表、编辑态(闸/孪生件/增删行/红带)、PRD 平移法、陷阱表 |
| | `docs/前端对接说明-统一鉴权中心.md` | **前端接统一鉴权中心照这份**(ADR-012):要改哪些文件、`/api/me` 与端、401/403/410、SSE 与下载的票据 |
| | `docs/前端对接说明-邮件收发.md` | **前端接邮箱配置 / 来信 / 发信记录照这份**(ADR-013):账号表单两形、四个动作、投递状态、附件与原件的票据下载、SSE 失效、腾讯侧前置 |
| | `docs/API文档-邮件收发.md` | 邮件收发 15 条接口的人话版 + schema 附录 + curl;活动怎么收(事件链)怎么发(`mail.send` 2.0.0) |
| | `docs/API文档-鉴权与人员.md` | 鉴权 / 人员 / 角色→端接口的人话版 + 全部路由归属总表;契约不一致时以 `packages/contracts` 为准 |
| | `docs/debt.md` | 欠账台账(只增,销账划线) |
| | `docs/adr/` · 各模块 `CONTRACT.md` | 架构决策与模块级不变量 |
| 业务样例(活) | `docs/MVP示例工作流.html` | 七节点 SOP + 判定表 + 台账域 —— 开发活动的业务素材 |
@@ -1317,10 +1352,11 @@
3. **§12.5 禁止清单**:no `any` / `as unknown as` / `@ts-ignore`;domain 零 IO;
不删测试;不动门禁配置;不绕写三分/两缝。
4. **mock 只许出现在外界**(邮件通道、外部系统的录制回放替身);系统内部机制不得 mock。
5. **密钥只从环境读**:`JCAI_DEEPSEEK_API_KEY`(模型)、`JCAI_SEED_PASSWORD`(种子口令)、
`JCAI_SECRET_KEY`(凭证密封);不入源码、不入包、不入留痕;E2E 永不回退开发库。
6. **试跑一律用 AI 账号**(密码 `jcai-dev-2026`),绝不派到用户工作台;测完
`pnpm --filter @app/api reset-runs` 清运行数据(清跑出来的,留配置好的)。
5. **密钥只从环境读**:`JCAI_DEEPSEEK_API_KEY`(模型)、`JCAI_SECRET_KEY`(凭证密封 + 票据派生)、
`KEYCLOAK_URL` / `KEYCLOAK_REALM` / `KEYCLOAK_CLIENT_ID`(启动必填)、`KEYCLOAK_PROVISIONING_CLIENT_*`(名册同步,可选);
不入源码、不入包、不入留痕;E2E 永不回退开发库。
6. **试跑一律用 AI 服务账号**(Keycloak 里 `bpos-e2e` 的服务账号,client_credentials 拿 token;`JCAI_TEST_ACCOUNTS` 列上它的用户名),
绝不派到用户工作台;测完 `pnpm --filter @app/api reset-runs` 清运行数据(清跑出来的,留配置好的)。
7. **安装恒新增**:活动/工具改了内容必须升版本号,同版重装是 no-op(静默丢失,踩过)。
8. **界面不撒谎**:状态如实(未绑就写未绑)、错误带厂商原话、不可用不摆假数字。
+62
View File
@@ -0,0 +1,62 @@
/**
* module: mail —— 对外类型(编码规范 §10.1;设计稿 §5.1)。
*/
import type { MailAccountInput, MailAccountView, MailDeliveryView, MailInboundDetail, MailInboundSummary, MailOutboundView, MailPollOutcome, MailProbeReport, MailSendInput, MailSendResult } from '@contracts';
export type { MailAccountInput, MailAccountView, MailDeliveryView, MailInboundDetail, MailInboundSummary, MailOutboundView, MailPollOutcome, MailProbeReport, MailSendInput, MailSendResult, };
export { MailAccountDisabledError, MailAccountNameTakenError, MailAccountNotFoundError, MailAttachmentNotFoundError, MailInboundNotFoundError, MailNoDefaultAccountError, MailPollBusyError, MailSecretMissingError, MailSendInvalidError, MailSubscriberTakenError, MailSubscriberUnknownError, } from './internal/domain/errors.ts';
/**
* 订阅方回调 —— 「注册回调读到邮件内容」的落点。
* 返回 `{skipped}` 表示这封信与我无关(留痕 skipped,不算失败);抛错留痕 failed(原话脱敏)。
* **自动只投一次,不自动重试**(设计稿 §9.3);人按「重新投递」才再来。
*/
export type InboundHandler = (mail: MailInboundDetail, ctx: {
readonly accountId: string;
readonly channelId: string | undefined;
}) => Promise<void | {
readonly skipped: string;
}>;
export type MailService = {
listAccounts(): Promise<MailAccountView[]>;
getAccount(accountId: string): Promise<MailAccountView>;
createAccount(input: MailAccountInput): Promise<MailAccountView>;
/** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */
updateAccount(accountId: string, input: MailAccountInput): Promise<MailAccountView>;
/** 留痕不删(入站 / 出站表对账号不设外键) */
deleteAccount(accountId: string): Promise<void>;
/** 已存账号用库里凭证;未存用入参里的;一个字节都不落库(wecom 探到地址的已存账号除外:回填 address) */
probe(target: {
accountId: string;
} | {
input: MailAccountInput;
accountId?: string | undefined;
}): Promise<MailProbeReport>;
/** 工作者用:到期且未被占的账号各拉一轮(账号间并行) */
pollDue(now: Date): Promise<MailPollOutcome[]>;
/** 路由用:租约被占 → MailPollBusyError;停用 → MailAccountDisabledError */
pollNow(accountId: string): Promise<MailPollOutcome>;
/** 同名重复注册 → MailSubscriberTakenError;返回注销函数 */
subscribe(name: string, handler: InboundHandler): () => void;
listInbound(query: {
accountId?: string | undefined;
limit?: number | undefined;
}): Promise<MailInboundSummary[]>;
getInbound(mailId: string): Promise<MailInboundDetail>;
attachment(mailId: string, index: number): Promise<{
name: string;
contentType: string;
bytes: Uint8Array;
}>;
/** 原件 .eml;超限没存 → MailAttachmentNotFoundError(index = -1) */
raw(mailId: string): Promise<{
name: string;
bytes: Uint8Array;
}>;
/** 人按的钮:指定订阅方或全部;正在 dispatching 的那条跳过 */
redeliver(mailId: string, subscriber?: string): Promise<MailDeliveryView[]>;
/** 先落 sending 行再经 provider 发;厂商失败落 failed 并以 state='failed' 返回,不抛 */
send(input: MailSendInput): Promise<MailSendResult>;
listOutbound(query: {
accountId?: string | undefined;
limit?: number | undefined;
}): Promise<MailOutboundView[]>;
};
+21
View File
@@ -0,0 +1,21 @@
/**
* module: mail —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type { InboundHandler, MailAccountInput, MailAccountView, MailDeliveryView, MailInboundDetail, MailInboundSummary, MailOutboundView, MailPollOutcome, MailProbeReport, MailSendInput, MailSendResult, MailService, } from './contract.ts';
export { MailAccountDisabledError, MailAccountNameTakenError, MailAccountNotFoundError, MailAttachmentNotFoundError, MailInboundNotFoundError, MailNoDefaultAccountError, MailPollBusyError, MailSecretMissingError, MailSendInvalidError, MailSubscriberTakenError, MailSubscriberUnknownError, } from './contract.ts';
export type { AttachmentStore, ChangeNotifier, Clock, DeliveryRecord, IdGenerator, InboundRecord, InboundStore, MailAccountRecord, MailAccountStore, MailCursor, MailProvider, MimeParser, OutboundMessage, OutboundRecord, OutboundStore, ParsedMail, ProviderAccount, RawMessage, SecretBox, } from './port.ts';
export { advanceImap, cursorSummary, imapCursor, imapNeedsReanchor, newUids, parseCursor, wecomCursor, wecomWindow, } from './internal/domain/cursor.ts';
export { isDue, isPolling, leaseMsFor } from './internal/domain/due.ts';
export { ATTACHMENT_CAP, bodyTextOf, buildInboundRecord, detailOf, planAttachments, RAW_CAP, stripHtml, summaryOf, threadIdOf, } from './internal/domain/inbound-record.ts';
export { errorText, redactSecrets, secretVariants } from './internal/domain/redact.ts';
export { messageIdFor, pickAccount, validateSend } from './internal/domain/send-input.ts';
export { canRedeliver, stateOfOutcome } from './internal/domain/delivery.ts';
export { cursorReset, mergeAccount, secretOf, viewOf } from './internal/domain/account-view.ts';
export { createInboundStore, createMailAccountStore, createOutboundStore } from './internal/adapter/mail-store.ts';
export { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './internal/adapter/mail-schema.ts';
export { createPostalMimeParser } from './internal/adapter/postal-mime-parser.ts';
export type { MailServiceDeps } from './internal/usecase/mail-service.ts';
export { createMailService } from './internal/usecase/mail-service.ts';
export { createImapSmtpProvider } from './internal/adapter/imap-smtp-provider.ts';
export type { FetchLike } from './internal/adapter/wecom-provider.ts';
export { createWecomProvider, WecomApiError } from './internal/adapter/wecom-provider.ts';
+170
View File
@@ -0,0 +1,170 @@
/**
* module: mail —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3;设计稿 §5.2)。
*/
import type { MailAccountKind, MailProbeReport } from '@contracts';
import type { MailCursor } from './internal/domain/cursor.ts';
import type { DeliveryRecord, InboundRecord, MailAccountRecord, OutboundMessage, OutboundRecord, ParsedMail } from './internal/domain/records.ts';
export type { DeliveryRecord, InboundRecord, MailAccountRecord, MailCursor, OutboundMessage, OutboundRecord, ParsedMail };
/** 凭证密封 —— 与 llm / toolbox 同款,app 注入同一实例(ADR-009) */
export type SecretBox = {
seal(plaintext: string): string;
open(cipher: string): string;
};
export type Clock = {
now(): Date;
};
export type IdGenerator = {
next(): string;
};
export type MailAccountStore = {
list(): Promise<MailAccountRecord[]>;
get(id: string): Promise<MailAccountRecord | undefined>;
findByName(name: string): Promise<MailAccountRecord | undefined>;
/**
* upsert **只写配置列**;`isDefault:true` 时先把别的置 false(同一事务)。
* 运行列(cursor / lastPolledAt / lastPollError / lastInboundAt / pollingUntil)一概不动 —— 见 `savePoll`。
*/
save(record: MailAccountRecord): Promise<void>;
remove(id: string): Promise<void>;
/** enabled 且(从没拉过 或 last_polled_at + interval <= now)且租约空 / 过期 */
listDue(now: Date): Promise<MailAccountRecord[]>;
/** 一条 SQL 判 + 占(设计稿 §9.1);抢到 true */
claimPolling(id: string, leaseMs: number): Promise<boolean>;
releasePolling(id: string): Promise<void>;
/**
* 拉取过程的进度:游标 / 时刻 / 错误 / 最近来信 / 探到的地址;行不存在 = no-op。
*
* **运行列只走这条路**(与 `claimPolling` / `releasePolling` 一起)——`save()` 一个字都不碰它们:
* 运营面 PUT 的同时另一台正拉着,整行 upsert 会把租约与游标一并覆盖回读那一刻的快照
* (`sys.loop_state` 整份覆盖那一课的同款指纹)。
* `cursor: null` 是**显式清游标**(改了收侧配置要重锚时用)。
*/
savePoll(id: string, patch: {
cursor?: MailCursor | null;
polledAt?: Date;
error?: string | null;
lastInboundAt?: Date;
address?: string;
}): Promise<void>;
};
export type InboundStore = {
/** (account_id, message_id) / (account_id, provider_ref) 冲突 → inserted:false,不抛 */
insert(record: InboundRecord): Promise<{
inserted: boolean;
}>;
knownProviderRefs(accountId: string, refs: readonly string[]): Promise<Set<string>>;
/**
* 列表 —— **不取 `raw` 原件字节**(一封上限 16MB,50 行就是几百 MB,只为算 200 字预览);
* `rawAvailable` 由 `raw is not null` 投影出来,不靠把字节搬回来再判。
*/
list(query: {
accountId?: string | undefined;
limit: number;
}): Promise<Array<{
record: InboundRecord;
deliveries: DeliveryRecord[];
}>>;
/** 只要元信息的读(分发 / 详情 / 附件 / 重投都走它)—— 同样不取 `raw` 字节 */
getMeta(mailId: string): Promise<{
record: InboundRecord;
deliveries: DeliveryRecord[];
} | undefined>;
/** 全列读 —— 只有「下载原件」那一条路要它 */
get(mailId: string): Promise<{
record: InboundRecord;
deliveries: DeliveryRecord[];
} | undefined>;
/** upsert on (mail_id, subscriber) */
setDelivery(delivery: DeliveryRecord): Promise<void>;
};
export type OutboundStore = {
insert(record: OutboundRecord): Promise<void>;
byIdempotencyKey(key: string): Promise<OutboundRecord | undefined>;
/**
* 复用旧行重发之前**先抢**(一条条件更新,抢到才回 true):
* `failed` 的行、或 `created_at <= staleBefore` 的 `sending` 行才抢得到;抢到即置 `sending` 并把
* `created_at` 推到 `now` —— 这一手让那一行**离开可抢的集合**,第二个人当场抢不到。
*
* 本仓是两个后端连同一个库:没有这一抢,两台同时重放同一次放行都会判「超时可复用」,
* 发出**两封真邮件**,直接破掉契约白纸黑字的「同键不发两封」。
*/
claimForResend(id: string, opts: {
readonly staleBefore: Date;
readonly now: Date;
}): Promise<boolean>;
setState(id: string, state: 'sent' | 'failed', patch: {
error?: string;
providerRef?: string;
sentAt?: Date;
}): Promise<void>;
list(query: {
accountId?: string | undefined;
limit: number;
}): Promise<OutboundRecord[]>;
};
/** 附件存放 —— app 注入 = `sys.upload`(复用 8MB 上限、文本抽取、下载路由) */
export type AttachmentStore = {
put(file: {
name: string;
contentType: string;
bytes: Uint8Array;
note: string;
}): Promise<{
ref: string;
}>;
get(ref: string): Promise<{
name: string;
contentType: string;
bytes: Uint8Array;
} | undefined>;
};
/** 交给 provider 的账号:凭证明文只在这一层与 usecase 之间传,不落任何留痕 */
export type ProviderAccount = {
readonly id: string;
readonly kind: MailAccountKind;
readonly address: string;
readonly displayName: string;
readonly settings: Record<string, unknown>;
readonly secret: Record<string, string>;
};
export type RawMessage = {
readonly providerRef: string;
readonly eml: Uint8Array;
readonly receivedAt: Date;
/** 这一封之后游标该在哪(IMAP 逐封给;wecom 不给,整批完才推进) */
readonly cursorAfter?: MailCursor | undefined;
};
/** 外界:一种账号类型一个适配器(设计稿 §5.5) */
export type MailProvider = {
readonly kind: MailAccountKind;
probe(account: ProviderAccount): Promise<MailProbeReport>;
/** 无游标时锚定初始游标(不回填;设了 backfillFrom 才从那天起) */
anchor(account: ProviderAccount, backfillFrom: Date | null, now: Date): Promise<MailCursor>;
/**
* 拉一批。回参的 `note` 是这一轮**不算失败、但人必须知道**的事
* (当前唯一一种:IMAP 的 UIDVALIDITY 变了、已重锚在当下、期间来信不回填)——
* 用例把它落进 `last_poll_error`。丢了一段信而界面一片绿,那是界面撒谎。
*/
pull(account: ProviderAccount, cursor: MailCursor, opts: {
readonly limit: number;
readonly now: Date;
readonly backfillFrom: Date | null;
/** 库里已有的 provider_ref —— wecom 用它在 read_mail 之前把重叠窗里的旧信滤掉;IMAP 靠 UID 游标不用 */
readonly known: (refs: readonly string[]) => Promise<Set<string>>;
}): Promise<{
messages: RawMessage[];
cursor: MailCursor;
more: boolean;
note?: string | undefined;
}>;
send(account: ProviderAccount, message: OutboundMessage): Promise<{
providerRef?: string | undefined;
}>;
};
export type MimeParser = {
parse(eml: Uint8Array): Promise<ParsedMail>;
};
/** 变更通知 —— app 接到 broadcaster;模块不知道 SSE 的存在 */
export type ChangeNotifier = {
changed(reason: string): void;
};
+50 -35
View File
@@ -1,16 +1,26 @@
/**
* module: org —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。
*
* 身份归统一鉴权中心(ADR-012):`Staff.id` 就是 Keycloak 的 `sub`。
* 本模块管三件事:①验签后的人是谁(名册即时更新);②他能进哪几个端(角色→端);③岗位与工作组。
* 口令 / 会话 / 岗位授权(客体三类 × 动作三种)已退役。
*/
import type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, StaffId, WorkGroup, WorkGroupBinding } from '@contracts';
export type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, WorkGroup, WorkGroupBinding };
import type { InFlightWork, Position, PositionId, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffId, Surface, WorkGroup, WorkGroupBinding } from '@contracts';
export type { InFlightWork, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding };
/** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */
export type StaffWithPosition = Staff & {
readonly position: Position;
};
export type CreateStaffInput = {
/** 从 token 或 Admin API 归一出来的一个人 —— `sub` 即 Staff.id */
export type Identity = {
readonly sub: string;
/** preferred_username */
readonly account: string;
/** name claim,缺则回退 account */
readonly name: string;
readonly positionId: string;
readonly email?: string;
/** realm 角色,噪声角色已滤、已排序 */
readonly roles: readonly string[];
};
export declare class StaffNotFoundError extends Error {
readonly staffId: StaffId;
@@ -20,45 +30,51 @@ export declare class PositionNotFoundError extends Error {
readonly positionCode: string;
constructor(positionCode: string);
}
export declare class AccountTakenError extends Error {
/** 验签没过 —— message 是手册 4.4 的人话,中间件原样回 401 */
export declare class TokenRejectedError extends Error {
constructor(message: string);
}
/** 名册同步标了停用 —— 即使 token 还有效也拒(403);Keycloak 恢复后再同步一次 */
export declare class AccountDisabledError extends Error {
readonly account: string;
constructor(account: string);
}
/** 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02) */
export declare class DisableBlockedError extends Error {
readonly check: DisableCheck;
constructor(check: DisableCheck);
/**
* 这一步做下去就没有任何在册活跃员工能进运营中心了 —— 拒绝(路由映射 409)。
* 两个来路:改「角色 → 端」(那张表就再也没人改得了)、同步名册(同步按钮自己也要 ops)。
* `message` 可覆盖:拒绝的理由不同,给人的下一步也不同。
*/
export declare class AccessLockoutError extends Error {
constructor(message?: string);
}
/** 登录结果 —— token 交给 app 层种 cookie;失败一律 undefined,不区分原因 */
export type IssuedSession = {
readonly token: string;
readonly expiresAt: string;
};
export type OrgService = {
/** 验签 → 名册即时更新 → 角色→端。抛 TokenRejectedError(401)/ AccountDisabledError(403) */
authenticate(token: string): Promise<Principal>;
/** 票据那条路:按 sub 从库现算;停用抛 AccountDisabledError;不存在回 undefined */
principalOf(staffId: StaffId): Promise<Principal | undefined>;
/** 名册即时更新的入口 —— 没有就建(NONE 岗、active),有就把变了的列写回;测试也用它造人 */
registerIdentity(identity: Identity): Promise<Staff>;
listStaff(): Promise<{
staff: StaffWithPosition[];
positions: Position[];
}>;
getStaff(staffId: StaffId): Promise<StaffWithPosition>;
findByAccount(account: string): Promise<StaffWithPosition | undefined>;
createStaff(input: CreateStaffInput): Promise<Staff>;
/** 停用前置检查 —— 只读,治理台先看再点 */
checkDisable(staffId: StaffId): Promise<DisableCheck>;
/** 停用 —— 前置检查不过即抛 DisableBlockedError,不做部分执行 */
disableStaff(staffId: StaffId): Promise<void>;
listGrants(positionId: string): Promise<Grant[]>;
setGrants(positionId: string, grants: readonly Grant[]): Promise<Grant[]>;
/** 岗位权限校验 —— app 层鉴权中间件读它 */
authorize(staffId: StaffId, object: GrantObject, action: GrantAction): Promise<boolean>;
loadSession(account: string): Promise<Session | undefined>;
/** 口令核验通过即签发会话;账号不存在 / 已停用 / 未设口令 / 口令错,一律 undefined */
login(account: string, password: string): Promise<IssuedSession | undefined>;
/** 登出:该 token 立刻作废(服务端表,不是签名 cookie) */
logout(token: string): Promise<void>;
/** 解析会话 —— 命中即滑动续期;过期或已作废返回 undefined */
resolveSession(token: string): Promise<Session | undefined>;
/** 设定口令 —— 明文只在此处停留一瞬,落库前即散列 */
setPassword(staffId: StaffId, password: string): Promise<void>;
/** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用);只回 active;NONE 恒回 [] */
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
setPosition(staffId: StaffId, positionId: PositionId): Promise<StaffWithPosition>;
/** Admin API 同步;未配 provisioning 回 configured:false */
syncRoster(): Promise<RosterSyncOutcome>;
/** 此人身上还挂着什么(只读;OS 02 改派先于停用) */
inFlightOf(staffId: StaffId): Promise<InFlightWork>;
listRoleAccess(): Promise<{
roles: RoleAccess[];
knownRoles: string[];
}>;
/** 抛 AccessLockoutError(409):改完没有任何在册活跃员工能进运营中心 */
setRoleAccess(role: string, ends: readonly Surface[]): Promise<RoleAccess>;
deleteRoleAccess(role: string): Promise<void>;
/** 能进该端的活跃员工 —— 治理台通知的收件人 */
staffWithEnd(surface: Surface): Promise<StaffWithPosition[]>;
/** 建组 / 改组;id 留空即新建 */
saveWorkGroup(input: {
id?: string;
@@ -70,7 +86,6 @@ export type OrgService = {
workGroupOf(staffId: StaffId): Promise<WorkGroup | undefined>;
/** 装进工作流的快照:改组不改已起的单 */
bindingOf(staffId: StaffId): Promise<WorkGroupBinding | undefined>;
/** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用) */
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
/** 岗位(含 NONE / TEST)+ 缺省角色→端(表空时)+ 已在册测试账号的策略;幂等 */
ensureSeeded(): Promise<void>;
};
+13 -8
View File
@@ -1,13 +1,18 @@
/**
* module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type { CreateStaffInput, DisableCheck, Grant, GrantAction, GrantObject, IssuedSession, OrgService, Position, Session, Staff, StaffWithPosition, WorkGroup, WorkGroupBinding, } from './contract.ts';
export { AccountTakenError, DisableBlockedError, PositionNotFoundError, StaffNotFoundError, } from './contract.ts';
export type { Clock, GrantRepository, IdGenerator, InFlightWorkProbe, PasswordHasher, PositionRepository, SessionRepository, StaffRepository, WorkGroupRepository, } from './port.ts';
export type { Identity, InFlightWork, OrgService, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffWithPosition, Surface, WorkGroup, WorkGroupBinding, } from './contract.ts';
export { AccessLockoutError, AccountDisabledError, PositionNotFoundError, StaffNotFoundError, TokenRejectedError, } from './contract.ts';
export type { Clock, IdGenerator, IdentityPatch, InFlightWorkProbe, PositionRepository, RoleAccessRepository, RosterSource, RosterUser, StaffRecord, StaffRepository, TokenVerifier, VerifiedClaims, WorkGroupRepository, } from './port.ts';
export type { OrgServiceDeps } from './internal/usecase/org-service.ts';
export { createOrgService } from './internal/usecase/org-service.ts';
export { isAuthorized, actionCovers, objectCovers } from './internal/domain/authorization.ts';
export { DEFAULT_LOGIN_ACCOUNT, POSITION_SEED, STAFF_SEED } from './internal/domain/seed-data.ts';
export { createGrantRepository, createPositionRepository, createSessionRepository, createStaffRepository, createWorkGroupRepository, objectKeyOf, } from './internal/adapter/org-repository.ts';
export { hashPassword, newSessionToken, verifyPassword } from './internal/adapter/password.ts';
export { grantTable, positionTable, sessionTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts';
export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts';
export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts';
export { buildInFlightWork } from './internal/domain/in-flight.ts';
export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from './internal/domain/seed-data.ts';
export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts';
export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts';
export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts';
export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts';
export { createPositionRepository, createRoleAccessRepository, createStaffRepository, createWorkGroupRepository, } from './internal/adapter/org-repository.ts';
export { positionTable, roleAccessTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts';
+54 -38
View File
@@ -2,10 +2,40 @@
* module: org —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
* adapter 实现之,usecase 依赖之,app 装配之。
*/
import type { Grant, Position, PositionId, Staff, StaffId, WorkGroup } from '@contracts';
import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts';
import type { Identity } from './contract.ts';
export type Clock = {
now(): Date;
};
/** 验签通过后的 claims —— 只认手册 §17 列的那几个 */
export type VerifiedClaims = {
readonly sub: string;
readonly preferred_username: string;
readonly name?: string;
readonly email?: string;
readonly realm_access?: {
readonly roles?: readonly string[];
};
};
/** 验签 —— 实现在 adapter(jose);失败抛 TokenRejectedError;测试注入本地密钥对 */
export type TokenVerifier = {
verify(token: string): Promise<VerifiedClaims>;
};
/** 名册来源(Keycloak Admin API)—— app 未配 provisioning 服务账号时不注入 */
export type RosterUser = Identity & {
readonly enabled: boolean;
};
export type RosterSource = {
listUsers(): Promise<readonly RosterUser[]>;
};
/** 名册即时更新 / 同步要改的那几列 */
export type IdentityPatch = {
readonly account: string;
readonly name: string;
readonly email?: string;
readonly roles: readonly string[];
readonly lastSeenAt?: Date;
};
export type PositionRepository = {
list(): Promise<Position[]>;
findByCode(code: string): Promise<Position | undefined>;
@@ -14,46 +44,32 @@ export type PositionRepository = {
id?: PositionId;
}): Promise<Position>;
};
/** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */
export type StaffRecord = Staff & {
readonly lastSeenAt?: Date;
readonly syncedAt?: Date;
};
export type StaffRepository = {
list(): Promise<Staff[]>;
findById(id: StaffId): Promise<Staff | undefined>;
findByAccount(account: string): Promise<Staff | undefined>;
listByPosition(positionId: PositionId): Promise<Staff[]>;
insert(staff: Staff): Promise<Staff>;
setState(id: StaffId, state: Staff['state']): Promise<void>;
/** 口令散列的读写 —— 明文不经此端口(ADR-010) */
passwordHashOf(account: string): Promise<string | undefined>;
setPasswordHash(id: StaffId, hash: string): Promise<void>;
list(): Promise<StaffRecord[]>;
findById(id: StaffId): Promise<StaffRecord | undefined>;
/** 按账号找 —— `account` 有唯一索引,建档前得先问一句「这个名字被谁占着」(同名让位) */
findByAccount(account: string): Promise<StaffRecord | undefined>;
listByPosition(positionId: PositionId): Promise<StaffRecord[]>;
insert(staff: Staff, marks: {
readonly lastSeenAt?: Date;
readonly syncedAt?: Date;
}): Promise<StaffRecord>;
updateIdentity(id: StaffId, patch: IdentityPatch): Promise<void>;
setPosition(id: StaffId, positionId: PositionId): Promise<void>;
setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise<void>;
};
export type RoleAccessRepository = {
list(): Promise<RoleAccess[]>;
upsert(row: RoleAccess): Promise<RoleAccess>;
remove(role: string): Promise<void>;
};
/**
* 会话仓储(ADR-010)—— 服务端表:登出即刻作废,停用员工连带清场。
* token 即主键,不透明随机串,不编码任何身份信息。
*/
export type SessionRepository = {
issue(input: {
token: string;
staffId: StaffId;
expiresAt: Date;
}): Promise<void>;
/** 未过期才返回;命中同时滑动续期 */
touch(token: string, now: Date, expiresAt: Date): Promise<{
staffId: StaffId;
} | undefined>;
revoke(token: string): Promise<void>;
revokeAllOf(staffId: StaffId): Promise<void>;
};
/** 口令散列器 —— 算法实现在 adapter,usecase 只认这两个动作 */
export type PasswordHasher = {
hash(password: string): Promise<string>;
verify(password: string, stored: string | undefined): Promise<boolean>;
newToken(): string;
};
export type GrantRepository = {
listByPosition(positionId: PositionId): Promise<Grant[]>;
replaceForPosition(positionId: PositionId, grants: readonly Grant[]): Promise<Grant[]>;
};
/**
* 在途工作探针 —— 停用前置检查要问的是 L6 `task` 与 L3 `human-tool`,
* 在途工作探针 —— 要问的是 L6 `task` 与 L3 `human-tool`,
* 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。
*/
export type InFlightWorkProbe = {
+7
View File
@@ -85,6 +85,13 @@ export type TaskService = {
/** 控制三语义:暂停 / 叫停 / 终止 */
control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise<ControlOutcome>;
resume(taskId: TaskId, by: StaffId): Promise<Task>;
/**
* **人答复落库那一刻就把「在办事」写进投影**(2026-08-25):不等派发器投递、推进器
* 抢到租约再翻 —— 那几秒里对话区(卡状态已落)与工作区(读 runtime)说两句相反的话。
* 只在挂起等人 / 等外部时翻;已终结、暂停(suspended)、已在跑的原样不动。
* 返回翻没翻 —— 调用方据此决定要不要广播。
*/
wake(taskId: TaskId): Promise<boolean>;
/** 改派:在途任务的负责人变更,**留痕并通知双方** */
reassign(taskId: TaskId, to: StaffId, by: StaffId): Promise<Task>;
/** 终态裁定 —— 完成由框架裁定,终态由系统命名 */
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
export {};
+194
View File
@@ -6,16 +6,19 @@ import { z } from 'zod';
export declare const listStaffRoute: {
readonly method: "GET";
readonly path: "/api/gov/staff";
readonly access: readonly ["design", "ops"];
readonly output: z.ZodObject<{
staff: z.ZodArray<z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
account: z.ZodString;
name: z.ZodString;
email: z.ZodOptional<z.ZodString>;
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
state: z.ZodDefault<z.ZodEnum<{
active: "active";
disabled: "disabled";
}>>;
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
position: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
code: z.ZodString;
@@ -31,9 +34,11 @@ export declare const listStaffRoute: {
}, z.core.$strip>>;
}, z.core.$strip>;
};
/** @deprecated 随 ADR-012 退役,后端回 410;人来自 Keycloak,改用 syncStaffRoute */
export declare const createStaffRoute: {
readonly method: "POST";
readonly path: "/api/gov/staff";
readonly access: "public";
readonly body: z.ZodObject<{
account: z.ZodString;
name: z.ZodString;
@@ -43,16 +48,20 @@ export declare const createStaffRoute: {
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
account: z.ZodString;
name: z.ZodString;
email: z.ZodOptional<z.ZodString>;
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
state: z.ZodDefault<z.ZodEnum<{
active: "active";
disabled: "disabled";
}>>;
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>;
};
/** @deprecated 随 ADR-012 退役,后端回 410;改用 staffInFlightRoute */
export declare const disableStaffCheckRoute: {
readonly method: "GET";
readonly path: "/api/gov/staff/:staffId/disable-check";
readonly access: "public";
readonly params: z.ZodObject<{
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
}, z.core.$strip>;
@@ -73,9 +82,11 @@ export declare const disableStaffCheckRoute: {
}, z.core.$strip>>>;
}, z.core.$strip>;
};
/** @deprecated 随 ADR-012 退役,后端回 410;停用改在 Keycloak 做,名册同步据此落 disabled */
export declare const disableStaffRoute: {
readonly method: "POST";
readonly path: "/api/gov/staff/:staffId/disable";
readonly access: "public";
readonly params: z.ZodObject<{
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
}, z.core.$strip>;
@@ -99,9 +110,11 @@ export declare const disableStaffRoute: {
}, z.core.$strip>;
}, z.core.$strip>;
};
/** @deprecated 随 ADR-012 退役,后端回 410;权限只到端,改用 listRoleAccessRoute */
export declare const listGrantsRoute: {
readonly method: "GET";
readonly path: "/api/gov/positions/:positionId/grants";
readonly access: "public";
readonly params: z.ZodObject<{
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
}, z.core.$strip>;
@@ -138,9 +151,11 @@ export declare const listGrantsRoute: {
}, z.core.$strip>>;
}, z.core.$strip>;
};
/** @deprecated 随 ADR-012 退役,后端回 410;权限只到端,改用 setRoleAccessRoute */
export declare const setGrantsRoute: {
readonly method: "POST";
readonly path: "/api/gov/positions/:positionId/grants";
readonly access: "public";
readonly params: z.ZodObject<{
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
}, z.core.$strip>;
@@ -209,6 +224,132 @@ export declare const setGrantsRoute: {
}, z.core.$strip>>;
}, z.core.$strip>;
};
/** 从 Keycloak Admin API 同步名册;没配 provisioning 服务账号就如实回 configured:false */
export declare const syncStaffRoute: {
readonly method: "POST";
readonly path: "/api/gov/staff/sync";
readonly access: "ops";
readonly output: z.ZodDiscriminatedUnion<[z.ZodObject<{
configured: z.ZodLiteral<false>;
message: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
configured: z.ZodLiteral<true>;
inserted: z.ZodNumber;
updated: z.ZodNumber;
disabled: z.ZodNumber;
total: z.ZodNumber;
failed: z.ZodArray<z.ZodObject<{
account: z.ZodString;
reason: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>], "configured">;
};
/** 指派岗位;指回哨兵岗 NONE = 取消指派 */
export declare const setStaffPositionRoute: {
readonly method: "PUT";
readonly path: "/api/gov/staff/:staffId/position";
readonly access: "ops";
readonly params: z.ZodObject<{
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
}, z.core.$strip>;
readonly body: z.ZodObject<{
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
}, z.core.$strip>;
readonly output: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
account: z.ZodString;
name: z.ZodString;
email: z.ZodOptional<z.ZodString>;
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
state: z.ZodDefault<z.ZodEnum<{
active: "active";
disabled: "disabled";
}>>;
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
position: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
code: z.ZodString;
name: z.ZodString;
note: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
}, z.core.$strip>;
};
/** 此人身上还挂着什么(只读)—— 原「停用前置检查」;停用本身在 Keycloak 做 */
export declare const staffInFlightRoute: {
readonly method: "GET";
readonly path: "/api/gov/staff/:staffId/in-flight";
readonly access: "ops";
readonly params: z.ZodObject<{
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
}, z.core.$strip>;
readonly output: z.ZodObject<{
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
blocked: z.ZodBoolean;
openTasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
taskId: z.ZodString;
title: z.ZodString;
}, z.core.$strip>>>;
openCards: z.ZodDefault<z.ZodArray<z.ZodObject<{
cardId: z.ZodString;
title: z.ZodString;
}, z.core.$strip>>>;
openApprovals: z.ZodDefault<z.ZodArray<z.ZodObject<{
approvalId: z.ZodString;
title: z.ZodString;
}, z.core.$strip>>>;
}, z.core.$strip>;
};
export declare const listRoleAccessRoute: {
readonly method: "GET";
readonly path: "/api/gov/access/roles";
readonly access: "ops";
readonly output: z.ZodObject<{
roles: z.ZodArray<z.ZodObject<{
role: z.ZodString;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
}, z.core.$strip>>;
knownRoles: z.ZodArray<z.ZodString>;
}, z.core.$strip>;
};
/** 锁死不变量不过(改完没人能进运营中心)→ 409 conflict */
export declare const setRoleAccessRoute: {
readonly method: "PUT";
readonly path: "/api/gov/access/roles/:role";
readonly access: "ops";
readonly params: z.ZodObject<{
role: z.ZodString;
}, z.core.$strip>;
readonly body: z.ZodObject<{
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
role: z.ZodString;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
}, z.core.$strip>;
};
export declare const deleteRoleAccessRoute: {
readonly method: "DELETE";
readonly path: "/api/gov/access/roles/:role";
readonly access: "ops";
readonly params: z.ZodObject<{
role: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodObject<{
ok: z.ZodBoolean;
}, z.core.$strip>;
};
export declare const LLM_PROVIDERS: readonly ["openai-compatible", "openai", "deepseek", "anthropic", "qwen"];
export declare const llmProviderSchema: z.ZodEnum<{
"openai-compatible": "openai-compatible";
@@ -243,6 +384,7 @@ export type LlmInstanceView = z.infer<typeof llmInstanceViewSchema>;
export declare const listLlmProvidersRoute: {
readonly method: "GET";
readonly path: "/api/gov/llm/providers";
readonly access: "design";
readonly output: z.ZodObject<{
providers: z.ZodArray<z.ZodObject<{
provider: z.ZodEnum<{
@@ -264,6 +406,7 @@ export declare const listLlmProvidersRoute: {
export declare const listLlmInstancesRoute: {
readonly method: "GET";
readonly path: "/api/gov/llm";
readonly access: "design";
readonly output: z.ZodObject<{
instances: z.ZodArray<z.ZodObject<{
id: z.ZodString;
@@ -286,6 +429,7 @@ export declare const listLlmInstancesRoute: {
export declare const upsertLlmInstanceRoute: {
readonly method: "POST";
readonly path: "/api/gov/llm";
readonly access: "design";
readonly body: z.ZodObject<{
id: z.ZodOptional<z.ZodString>;
name: z.ZodString;
@@ -331,6 +475,7 @@ export declare const upsertLlmInstanceRoute: {
export declare const deleteLlmInstanceRoute: {
readonly method: "DELETE";
readonly path: "/api/gov/llm/:id";
readonly access: "design";
readonly params: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
@@ -341,6 +486,7 @@ export declare const deleteLlmInstanceRoute: {
export declare const probeLlmDraftRoute: {
readonly method: "POST";
readonly path: "/api/gov/llm/probe";
readonly access: "design";
readonly body: z.ZodObject<{
id: z.ZodOptional<z.ZodString>;
name: z.ZodOptional<z.ZodString>;
@@ -364,6 +510,7 @@ export declare const probeLlmDraftRoute: {
export declare const probeLlmInstanceRoute: {
readonly method: "POST";
readonly path: "/api/gov/llm/:id/probe";
readonly access: "design";
readonly params: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
@@ -402,6 +549,7 @@ export type GovTaskRow = z.infer<typeof govTaskRowSchema>;
export declare const listGovTasksRoute: {
readonly method: "GET";
readonly path: "/api/gov/tasks";
readonly access: "ops";
readonly output: z.ZodObject<{
tasks: z.ZodArray<z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
@@ -447,6 +595,7 @@ export declare const listGovTasksRoute: {
export declare const controlTaskRoute: {
readonly method: "POST";
readonly path: "/api/gov/tasks/:taskId/control";
readonly access: "ops";
readonly params: z.ZodObject<{
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
}, z.core.$strip>;
@@ -466,6 +615,7 @@ export declare const controlTaskRoute: {
export declare const resumeTaskRoute: {
readonly method: "POST";
readonly path: "/api/gov/tasks/:taskId/resume";
readonly access: "ops";
readonly params: z.ZodObject<{
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
}, z.core.$strip>;
@@ -485,6 +635,7 @@ export declare const resumeTaskRoute: {
export declare const reassignTaskRoute: {
readonly method: "POST";
readonly path: "/api/gov/tasks/:taskId/reassign";
readonly access: "ops";
readonly params: z.ZodObject<{
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
}, z.core.$strip>;
@@ -500,6 +651,7 @@ export declare const reassignTaskRoute: {
export declare const govTaskLogRoute: {
readonly method: "GET";
readonly path: "/api/gov/tasks/:taskId/log";
readonly access: "ops";
readonly params: z.ZodObject<{
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
}, z.core.$strip>;
@@ -516,6 +668,7 @@ export declare const govTaskLogRoute: {
export declare const listProcessPackagesRoute: {
readonly method: "GET";
readonly path: "/api/gov/process";
readonly access: readonly ["design", "ops"];
readonly output: z.ZodObject<{
packages: z.ZodArray<z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "ProcessPackageId", "out">;
@@ -635,6 +788,7 @@ export declare const listProcessPackagesRoute: {
export declare const saveProcessRoute: {
readonly method: "PUT";
readonly path: "/api/gov/process";
readonly access: "design";
/**
* 编辑即其身:pkg.version 已存在 → 就地写回该版(号不变)。
* `asNew: true`(或版本不存在,如新流程首版)→ 产生新版本号(服务端算,不生效)。
@@ -879,6 +1033,7 @@ export declare const saveProcessRoute: {
export declare const setProcessStateRoute: {
readonly method: "POST";
readonly path: "/api/gov/process/:id/:version/state";
readonly access: "design";
readonly params: z.ZodObject<{
id: z.ZodString;
version: z.ZodString;
@@ -1005,6 +1160,7 @@ export declare const setProcessStateRoute: {
export declare const deleteProcessVersionRoute: {
readonly method: "DELETE";
readonly path: "/api/gov/process/:id/:version";
readonly access: "design";
readonly params: z.ZodObject<{
id: z.ZodString;
version: z.ZodString;
@@ -1121,6 +1277,7 @@ export declare const deleteProcessVersionRoute: {
export declare const listApprovalTemplatesRoute: {
readonly method: "GET";
readonly path: "/api/gov/approval-templates";
readonly access: "design";
readonly output: z.ZodObject<{
templates: z.ZodArray<z.ZodObject<{
id: z.ZodString;
@@ -1151,6 +1308,7 @@ export declare const listApprovalTemplatesRoute: {
export declare const upsertApprovalTemplateRoute: {
readonly method: "PUT";
readonly path: "/api/gov/approval-templates";
readonly access: "design";
readonly body: z.ZodObject<{
template: z.ZodObject<{
id: z.ZodString;
@@ -1198,6 +1356,7 @@ export declare const upsertApprovalTemplateRoute: {
export declare const deleteApprovalTemplateRoute: {
readonly method: "DELETE";
readonly path: "/api/gov/approval-templates/:id";
readonly access: "design";
readonly params: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
@@ -1213,6 +1372,7 @@ export declare const deleteApprovalTemplateRoute: {
export declare const deleteProcessFlowRoute: {
readonly method: "DELETE";
readonly path: "/api/gov/process/:id";
readonly access: "design";
readonly params: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
@@ -1335,6 +1495,7 @@ export declare const deleteProcessFlowRoute: {
export declare const editProcessVersionRoute: {
readonly method: "POST";
readonly path: "/api/gov/process/:id/:version/edit";
readonly access: "design";
readonly params: z.ZodObject<{
id: z.ZodString;
version: z.ZodString;
@@ -1455,6 +1616,7 @@ export declare const editProcessVersionRoute: {
export declare const validateProcessRoute: {
readonly method: "POST";
readonly path: "/api/gov/process/validate";
readonly access: "design";
readonly body: z.ZodObject<{
pkg: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "ProcessPackageId", "out">;
@@ -1580,6 +1742,7 @@ export declare const validateProcessRoute: {
export declare const listTriggersRoute: {
readonly method: "GET";
readonly path: "/api/gov/triggers";
readonly access: readonly ["design", "ops"];
readonly output: z.ZodObject<{
triggers: z.ZodArray<z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "TriggerId", "out">;
@@ -1639,6 +1802,7 @@ export declare const listTriggersRoute: {
export declare const toggleTriggerRoute: {
readonly method: "POST";
readonly path: "/api/gov/triggers/:id/toggle";
readonly access: "ops";
readonly params: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
@@ -1659,6 +1823,7 @@ export declare const toggleTriggerRoute: {
export declare const upsertTriggerRoute: {
readonly method: "PUT";
readonly path: "/api/gov/triggers";
readonly access: "design";
readonly body: z.ZodObject<{
trigger: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "TriggerId", "out">;
@@ -1779,6 +1944,7 @@ export declare const upsertTriggerRoute: {
export declare const listWorkGroupsRoute: {
readonly method: "GET";
readonly path: "/api/gov/work-groups";
readonly access: "ops";
/** `ownerName` 是组长姓名的呈现补充,读表时一并带出,免得前端再对一次 id */
readonly output: z.ZodObject<{
groups: z.ZodArray<z.ZodObject<{
@@ -1794,6 +1960,7 @@ export declare const listWorkGroupsRoute: {
export declare const saveWorkGroupRoute: {
readonly method: "PUT";
readonly path: "/api/gov/work-groups";
readonly access: "ops";
readonly body: z.ZodObject<{
ownerId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
@@ -1818,6 +1985,7 @@ export declare const saveWorkGroupRoute: {
export declare const installActivityRoute: {
readonly method: "POST";
readonly path: "/api/gov/activities";
readonly access: "design";
readonly body: z.ZodObject<{
manifest: z.ZodObject<{
identity: z.ZodObject<{
@@ -2126,6 +2294,7 @@ export declare const installActivityRoute: {
export declare const registerToolRoute: {
readonly method: "POST";
readonly path: "/api/gov/tools";
readonly access: "design";
readonly body: z.ZodObject<{
contract: z.ZodObject<{
name: z.ZodString;
@@ -2170,6 +2339,7 @@ export declare const registerToolRoute: {
export declare const listActivitiesRoute: {
readonly method: "GET";
readonly path: "/api/gov/activities";
readonly access: "design";
readonly output: z.ZodObject<{
activities: z.ZodArray<z.ZodObject<{
activityId: z.ZodString;
@@ -2228,6 +2398,7 @@ export declare const listActivitiesRoute: {
export declare const listToolsRoute: {
readonly method: "GET";
readonly path: "/api/gov/tools";
readonly access: "design";
readonly output: z.ZodObject<{
tools: z.ZodArray<z.ZodObject<{
name: z.ZodString;
@@ -2246,6 +2417,7 @@ export declare const listToolsRoute: {
export declare const listLedgerObjectsRoute: {
readonly method: "GET";
readonly path: "/api/gov/data/objects";
readonly access: readonly ["design", "ops"];
/** `label` 是人读名(落在表注释上);缺省即没登记,呈现退回对象键 */
readonly output: z.ZodObject<{
objects: z.ZodArray<z.ZodObject<{
@@ -2278,6 +2450,7 @@ export declare const listLedgerObjectsRoute: {
export declare const ledgerByActivityRoute: {
readonly method: "GET";
readonly path: "/api/gov/data/by-activity";
readonly access: readonly ["design", "ops"];
readonly output: z.ZodObject<{
activities: z.ZodArray<z.ZodObject<{
activityId: z.ZodString;
@@ -2313,6 +2486,7 @@ export declare const ledgerByActivityRoute: {
export declare const browseLedgerRoute: {
readonly method: "GET";
readonly path: "/api/gov/data/:domain/:object";
readonly access: readonly ["design", "ops"];
readonly params: z.ZodObject<{
domain: z.ZodString;
object: z.ZodString;
@@ -2332,6 +2506,7 @@ export declare const browseLedgerRoute: {
export declare const listActivityMemoryRoute: {
readonly method: "GET";
readonly path: "/api/gov/memory/:activityId";
readonly access: "design";
readonly params: z.ZodObject<{
activityId: z.ZodString;
}, z.core.$strip>;
@@ -2350,6 +2525,7 @@ export declare const listActivityMemoryRoute: {
export declare const listChannelsRoute: {
readonly method: "GET";
readonly path: "/api/gov/channels";
readonly access: readonly ["design", "ops"];
readonly output: z.ZodObject<{
channels: z.ZodArray<z.ZodObject<{
id: z.ZodString;
@@ -2382,6 +2558,7 @@ export declare const listChannelsRoute: {
export declare const listApprovalsRoute: {
readonly method: "GET";
readonly path: "/api/gov/approvals";
readonly access: "ops";
readonly output: z.ZodObject<{
approvals: z.ZodArray<z.ZodObject<{
id: z.ZodString;
@@ -2409,6 +2586,7 @@ export declare const listApprovalsRoute: {
export declare const setTriggerConfirmRoute: {
readonly method: "POST";
readonly path: "/api/gov/triggers/:id/confirm";
readonly access: "ops";
readonly params: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
@@ -2424,6 +2602,7 @@ export declare const setTriggerConfirmRoute: {
export declare const listNodeOwnersRoute: {
readonly method: "GET";
readonly path: "/api/gov/packages/:id/owners";
readonly access: "ops";
readonly params: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
@@ -2437,6 +2616,7 @@ export declare const listNodeOwnersRoute: {
export declare const setNodeOwnerRoute: {
readonly method: "PUT";
readonly path: "/api/gov/packages/:id/owners/:nodeId";
readonly access: "ops";
readonly params: z.ZodObject<{
id: z.ZodString;
nodeId: z.ZodString;
@@ -2452,6 +2632,7 @@ export declare const setNodeOwnerRoute: {
export declare const testRunRoute: {
readonly method: "POST";
readonly path: "/api/gov/testbed/run";
readonly access: "design";
readonly body: z.ZodObject<{
packageId: z.ZodString;
mail: z.ZodObject<{
@@ -2491,6 +2672,7 @@ export declare const testRunRoute: {
export declare const testRunActivityRoute: {
readonly method: "POST";
readonly path: "/api/gov/testbed/run-activity";
readonly access: "design";
readonly body: z.ZodObject<{
activityId: z.ZodString;
data: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
@@ -2510,6 +2692,7 @@ export declare const testRunActivityRoute: {
export declare const testStopRoute: {
readonly method: "POST";
readonly path: "/api/gov/testbed/stop";
readonly access: "design";
/** 两种试法各有其拔法:流程包给 workflowId,单活动给 taskId;都不给 = 拔掉所有试跑残留 */
readonly body: z.ZodObject<{
workflowId: z.ZodDefault<z.ZodString>;
@@ -2532,6 +2715,7 @@ export declare const testStopRoute: {
export declare const testCurrentRoute: {
readonly method: "GET";
readonly path: "/api/gov/testbed/current";
readonly access: "design";
readonly output: z.ZodObject<{
workflowId: z.ZodOptional<z.ZodString>;
subject: z.ZodOptional<z.ZodString>;
@@ -2551,6 +2735,7 @@ export declare const testCurrentRoute: {
export declare const testActivityStateRoute: {
readonly method: "GET";
readonly path: "/api/gov/testbed/activity/:taskId";
readonly access: "design";
readonly params: z.ZodObject<{
taskId: z.ZodString;
}, z.core.$strip>;
@@ -2617,6 +2802,7 @@ export declare const testActivityStateRoute: {
export declare const uploadFileRoute: {
readonly method: "POST";
readonly path: "/api/gov/upload";
readonly access: "design";
readonly body: z.ZodObject<{
name: z.ZodString;
contentType: z.ZodDefault<z.ZodString>;
@@ -2636,6 +2822,7 @@ export type UploadedFile = z.infer<typeof uploadFileRoute.output>;
export declare const downloadFileRoute: {
readonly method: "GET";
readonly path: "/api/gov/upload/:id";
readonly access: "design";
readonly params: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
@@ -2685,6 +2872,7 @@ export type TestLogRow = z.infer<typeof testLogRowSchema>;
export declare const listTestLogsRoute: {
readonly method: "GET";
readonly path: "/api/gov/testbed/logs";
readonly access: "design";
readonly output: z.ZodObject<{
runs: z.ZodArray<z.ZodObject<{
id: z.ZodString;
@@ -2720,6 +2908,7 @@ export declare const listTestLogsRoute: {
export declare const testLogDetailRoute: {
readonly method: "GET";
readonly path: "/api/gov/testbed/logs/:id";
readonly access: "design";
readonly params: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
@@ -2774,6 +2963,7 @@ export declare const testLogDetailRoute: {
export declare const deleteTestLogRoute: {
readonly method: "DELETE";
readonly path: "/api/gov/testbed/logs/:id";
readonly access: "design";
readonly params: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
@@ -2784,6 +2974,7 @@ export declare const deleteTestLogRoute: {
export declare const clearTestLogsRoute: {
readonly method: "DELETE";
readonly path: "/api/gov/testbed/logs";
readonly access: "design";
readonly output: z.ZodObject<{
deleted: z.ZodNumber;
}, z.core.$strip>;
@@ -2792,6 +2983,7 @@ export declare const clearTestLogsRoute: {
export declare const traceOfWorkflowRoute: {
readonly method: "GET";
readonly path: "/api/gov/workflows/:id/trace";
readonly access: "design";
readonly params: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
@@ -2825,6 +3017,7 @@ export declare const traceOfWorkflowRoute: {
export declare const govWorkflowRoute: {
readonly method: "GET";
readonly path: "/api/gov/workflows/:id";
readonly access: readonly ["design", "ops"];
readonly params: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
@@ -2986,6 +3179,7 @@ export declare const govWorkflowRoute: {
export declare const govMetricsRoute: {
readonly method: "GET";
readonly path: "/api/gov/metrics";
readonly access: "ops";
/** 统计窗口 —— 不传按月;驾驶舱可切日/周/月/年 */
readonly query: z.ZodObject<{
period: z.ZodOptional<z.ZodEnum<{
+23 -4
View File
@@ -4,16 +4,35 @@
* 前端只经 `shared/typed-fetch` + 本目录的路由契约与服务端交互;组件内禁止裸 fetch。
* **禁止 Hono RPC 客户端** —— 它要求前端 import 后端类型,违反 §5.1。
*/
import type { z } from 'zod';
import { z } from 'zod';
export declare const HTTP_METHODS: readonly ["GET", "POST", "PUT", "PATCH", "DELETE"];
export type HttpMethod = (typeof HTTP_METHODS)[number];
/**
* 三个端(ADR-012):设计中心 / 运营中心 / 工作台。
* 权限只到端 —— 一个角色能进哪几个端由运营中心的「角色 → 端」表定,不写死在代码里。
*/
export declare const SURFACES: readonly ["design", "ops", "workbench"];
export declare const surfaceSchema: z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>;
export type Surface = z.infer<typeof surfaceSchema>;
export declare const SURFACE_LABEL: Readonly<Record<Surface, string>>;
/**
* 一条路由归哪个端(ADR-012):
* `public` 不需要凭据;`any` 登录即可、不看端;端名 / 端名数组 —— 能进其中任一端即可。
* 鉴权中间件按它统一判,路由里不再各写守卫;漏写编译不过(`satisfies RouteContract`)。
*/
export type RouteAccess = 'public' | 'any' | Surface | readonly Surface[];
/**
* 一条路由契约。`path` 用 `:name` 表示路径参数,与 `params` 的键一一对应。
* body 只在 POST/PATCH 上有意义;GET 用 query。
* body 只在 POST/PUT/PATCH 上有意义;GET 用 query。
*/
export type RouteContract<TParams extends z.ZodTypeAny = z.ZodTypeAny, TQuery extends z.ZodTypeAny = z.ZodTypeAny, TBody extends z.ZodTypeAny = z.ZodTypeAny, TOutput extends z.ZodTypeAny = z.ZodTypeAny> = {
readonly method: HttpMethod;
readonly path: string;
readonly access: RouteAccess;
readonly params?: TParams;
readonly query?: TQuery;
readonly body?: TBody;
@@ -21,8 +40,8 @@ export type RouteContract<TParams extends z.ZodTypeAny = z.ZodTypeAny, TQuery ex
};
export type RouteOutput<R extends RouteContract> = z.infer<R['output']>;
export type RouteBody<R extends RouteContract> = R['body'] extends z.ZodTypeAny ? z.infer<R['body']> : never;
/** 结构化错误:前端按 code 分辨,不解析文案 */
export declare const API_ERROR_CODES: readonly ["unauthenticated", "forbidden", "not-found", "validation-failed", "conflict", "precondition-failed", "internal"];
/** 结构化错误:前端按 code 分辨,不解析文案。`gone` = 410,路由已随统一鉴权中心退役 */
export declare const API_ERROR_CODES: readonly ["unauthenticated", "forbidden", "not-found", "validation-failed", "conflict", "precondition-failed", "internal", "gone"];
export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
export type ApiError = {
readonly code: ApiErrorCode;
+1
View File
@@ -2,3 +2,4 @@ export * from './http.ts';
export * from './workbench.ts';
export * from './governance.ts';
export * from './ingest.ts';
export * from './mail.ts';
+1
View File
@@ -26,6 +26,7 @@ export type InboundMail = z.infer<typeof inboundMailSchema>;
export declare const ingestMailRoute: {
readonly method: "POST";
readonly path: "/api/ingest/channel/:channelId/mail";
readonly access: "public";
readonly params: z.ZodObject<{
channelId: z.core.$ZodBranded<z.ZodString, "ChannelId", "out">;
}, z.core.$strip>;
+690
View File
@@ -0,0 +1,690 @@
/**
* 邮件收发的路由契约(设计稿 §7.2;ADR-013)。
*
* 读写都归运营中心(设计稿 §7.2 + 2026-08-26 裁决):邮箱那一屏落在运营中心,设计端没有消费方;
* 而入站正文装的是客户来往内容 —— ADR-012 已把「设计端能读客户主数据」记为一处后果,不再无故加宽。
* `/api/gov/*` 的写路由由 app 中间件自动广播 `{kind:'system', id:'gov'}`。
* 探测 / 试发 / 拉取的**厂商失败不是接口错误**:200 的 body 里带原话(脱敏)。
*/
import { z } from 'zod';
import type { RouteContract } from './http.ts';
export declare const listMailAccountsRoute: {
readonly method: "GET";
readonly path: "/api/gov/mail/accounts";
readonly access: "ops";
readonly output: z.ZodObject<{
accounts: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
settings: z.ZodObject<{
imapHost: z.ZodDefault<z.ZodString>;
imapPort: z.ZodDefault<z.ZodNumber>;
imapTls: z.ZodDefault<z.ZodBoolean>;
smtpHost: z.ZodDefault<z.ZodString>;
smtpPort: z.ZodDefault<z.ZodNumber>;
smtpTls: z.ZodDefault<z.ZodBoolean>;
login: z.ZodOptional<z.ZodString>;
folder: z.ZodDefault<z.ZodString>;
}, z.core.$strip>;
id: z.ZodString;
name: z.ZodString;
address: z.ZodString;
displayName: z.ZodString;
enabled: z.ZodBoolean;
isDefault: z.ZodBoolean;
pollIntervalSec: z.ZodNumber;
channelId: z.ZodNullable<z.ZodString>;
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
secretBound: z.ZodBoolean;
status: z.ZodObject<{
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
lastPollError: z.ZodNullable<z.ZodString>;
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
cursor: z.ZodNullable<z.ZodString>;
polling: z.ZodBoolean;
}, z.core.$strip>;
createdAt: z.ZodISODateTime;
updatedAt: z.ZodISODateTime;
kind: z.ZodLiteral<"imap-smtp">;
}, z.core.$strip>, z.ZodObject<{
settings: z.ZodObject<{
corpId: z.ZodString;
apiBase: z.ZodDefault<z.ZodURL>;
}, z.core.$strip>;
id: z.ZodString;
name: z.ZodString;
address: z.ZodString;
displayName: z.ZodString;
enabled: z.ZodBoolean;
isDefault: z.ZodBoolean;
pollIntervalSec: z.ZodNumber;
channelId: z.ZodNullable<z.ZodString>;
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
secretBound: z.ZodBoolean;
status: z.ZodObject<{
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
lastPollError: z.ZodNullable<z.ZodString>;
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
cursor: z.ZodNullable<z.ZodString>;
polling: z.ZodBoolean;
}, z.core.$strip>;
createdAt: z.ZodISODateTime;
updatedAt: z.ZodISODateTime;
kind: z.ZodLiteral<"wecom">;
}, z.core.$strip>], "kind">>;
}, z.core.$strip>;
};
export declare const createMailAccountRoute: {
readonly method: "POST";
readonly path: "/api/gov/mail/accounts";
readonly access: "ops";
readonly body: z.ZodDiscriminatedUnion<[z.ZodObject<{
settings: z.ZodPrefault<z.ZodObject<{
imapHost: z.ZodDefault<z.ZodString>;
imapPort: z.ZodDefault<z.ZodNumber>;
imapTls: z.ZodDefault<z.ZodBoolean>;
smtpHost: z.ZodDefault<z.ZodString>;
smtpPort: z.ZodDefault<z.ZodNumber>;
smtpTls: z.ZodDefault<z.ZodBoolean>;
login: z.ZodOptional<z.ZodString>;
folder: z.ZodDefault<z.ZodString>;
}, z.core.$strip>>;
secret: z.ZodOptional<z.ZodObject<{
password: z.ZodString;
}, z.core.$strip>>;
name: z.ZodString;
displayName: z.ZodDefault<z.ZodString>;
enabled: z.ZodDefault<z.ZodBoolean>;
isDefault: z.ZodDefault<z.ZodBoolean>;
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
kind: z.ZodLiteral<"imap-smtp">;
address: z.ZodEmail;
}, z.core.$strip>, z.ZodObject<{
settings: z.ZodObject<{
corpId: z.ZodString;
apiBase: z.ZodDefault<z.ZodURL>;
}, z.core.$strip>;
secret: z.ZodOptional<z.ZodObject<{
corpSecret: z.ZodString;
}, z.core.$strip>>;
name: z.ZodString;
displayName: z.ZodDefault<z.ZodString>;
enabled: z.ZodDefault<z.ZodBoolean>;
isDefault: z.ZodDefault<z.ZodBoolean>;
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
kind: z.ZodLiteral<"wecom">;
}, z.core.$strip>], "kind">;
readonly output: z.ZodDiscriminatedUnion<[z.ZodObject<{
settings: z.ZodObject<{
imapHost: z.ZodDefault<z.ZodString>;
imapPort: z.ZodDefault<z.ZodNumber>;
imapTls: z.ZodDefault<z.ZodBoolean>;
smtpHost: z.ZodDefault<z.ZodString>;
smtpPort: z.ZodDefault<z.ZodNumber>;
smtpTls: z.ZodDefault<z.ZodBoolean>;
login: z.ZodOptional<z.ZodString>;
folder: z.ZodDefault<z.ZodString>;
}, z.core.$strip>;
id: z.ZodString;
name: z.ZodString;
address: z.ZodString;
displayName: z.ZodString;
enabled: z.ZodBoolean;
isDefault: z.ZodBoolean;
pollIntervalSec: z.ZodNumber;
channelId: z.ZodNullable<z.ZodString>;
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
secretBound: z.ZodBoolean;
status: z.ZodObject<{
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
lastPollError: z.ZodNullable<z.ZodString>;
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
cursor: z.ZodNullable<z.ZodString>;
polling: z.ZodBoolean;
}, z.core.$strip>;
createdAt: z.ZodISODateTime;
updatedAt: z.ZodISODateTime;
kind: z.ZodLiteral<"imap-smtp">;
}, z.core.$strip>, z.ZodObject<{
settings: z.ZodObject<{
corpId: z.ZodString;
apiBase: z.ZodDefault<z.ZodURL>;
}, z.core.$strip>;
id: z.ZodString;
name: z.ZodString;
address: z.ZodString;
displayName: z.ZodString;
enabled: z.ZodBoolean;
isDefault: z.ZodBoolean;
pollIntervalSec: z.ZodNumber;
channelId: z.ZodNullable<z.ZodString>;
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
secretBound: z.ZodBoolean;
status: z.ZodObject<{
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
lastPollError: z.ZodNullable<z.ZodString>;
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
cursor: z.ZodNullable<z.ZodString>;
polling: z.ZodBoolean;
}, z.core.$strip>;
createdAt: z.ZodISODateTime;
updatedAt: z.ZodISODateTime;
kind: z.ZodLiteral<"wecom">;
}, z.core.$strip>], "kind">;
};
/** 未存的配置探测 —— 表单上正填的那份,一个字节都不落库 */
export declare const probeMailDraftRoute: {
readonly method: "POST";
readonly path: "/api/gov/mail/accounts/probe";
readonly access: "ops";
/** 改已存账号时带上 ?accountId=:表单没重填密码就借库里那份密文 */
readonly query: z.ZodObject<{
accountId: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
readonly body: z.ZodDiscriminatedUnion<[z.ZodObject<{
settings: z.ZodPrefault<z.ZodObject<{
imapHost: z.ZodDefault<z.ZodString>;
imapPort: z.ZodDefault<z.ZodNumber>;
imapTls: z.ZodDefault<z.ZodBoolean>;
smtpHost: z.ZodDefault<z.ZodString>;
smtpPort: z.ZodDefault<z.ZodNumber>;
smtpTls: z.ZodDefault<z.ZodBoolean>;
login: z.ZodOptional<z.ZodString>;
folder: z.ZodDefault<z.ZodString>;
}, z.core.$strip>>;
secret: z.ZodOptional<z.ZodObject<{
password: z.ZodString;
}, z.core.$strip>>;
name: z.ZodString;
displayName: z.ZodDefault<z.ZodString>;
enabled: z.ZodDefault<z.ZodBoolean>;
isDefault: z.ZodDefault<z.ZodBoolean>;
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
kind: z.ZodLiteral<"imap-smtp">;
address: z.ZodEmail;
}, z.core.$strip>, z.ZodObject<{
settings: z.ZodObject<{
corpId: z.ZodString;
apiBase: z.ZodDefault<z.ZodURL>;
}, z.core.$strip>;
secret: z.ZodOptional<z.ZodObject<{
corpSecret: z.ZodString;
}, z.core.$strip>>;
name: z.ZodString;
displayName: z.ZodDefault<z.ZodString>;
enabled: z.ZodDefault<z.ZodBoolean>;
isDefault: z.ZodDefault<z.ZodBoolean>;
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
kind: z.ZodLiteral<"wecom">;
}, z.core.$strip>], "kind">;
readonly output: z.ZodObject<{
kind: z.ZodEnum<{
"imap-smtp": "imap-smtp";
wecom: "wecom";
}>;
address: z.ZodOptional<z.ZodString>;
receive: z.ZodObject<{
ok: z.ZodBoolean;
error: z.ZodOptional<z.ZodString>;
latencyMs: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>;
send: z.ZodObject<{
checked: z.ZodBoolean;
ok: z.ZodOptional<z.ZodBoolean>;
error: z.ZodOptional<z.ZodString>;
note: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
}, z.core.$strip>;
};
export declare const getMailAccountRoute: {
readonly method: "GET";
readonly path: "/api/gov/mail/accounts/:accountId";
readonly access: "ops";
readonly params: z.ZodObject<{
accountId: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodDiscriminatedUnion<[z.ZodObject<{
settings: z.ZodObject<{
imapHost: z.ZodDefault<z.ZodString>;
imapPort: z.ZodDefault<z.ZodNumber>;
imapTls: z.ZodDefault<z.ZodBoolean>;
smtpHost: z.ZodDefault<z.ZodString>;
smtpPort: z.ZodDefault<z.ZodNumber>;
smtpTls: z.ZodDefault<z.ZodBoolean>;
login: z.ZodOptional<z.ZodString>;
folder: z.ZodDefault<z.ZodString>;
}, z.core.$strip>;
id: z.ZodString;
name: z.ZodString;
address: z.ZodString;
displayName: z.ZodString;
enabled: z.ZodBoolean;
isDefault: z.ZodBoolean;
pollIntervalSec: z.ZodNumber;
channelId: z.ZodNullable<z.ZodString>;
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
secretBound: z.ZodBoolean;
status: z.ZodObject<{
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
lastPollError: z.ZodNullable<z.ZodString>;
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
cursor: z.ZodNullable<z.ZodString>;
polling: z.ZodBoolean;
}, z.core.$strip>;
createdAt: z.ZodISODateTime;
updatedAt: z.ZodISODateTime;
kind: z.ZodLiteral<"imap-smtp">;
}, z.core.$strip>, z.ZodObject<{
settings: z.ZodObject<{
corpId: z.ZodString;
apiBase: z.ZodDefault<z.ZodURL>;
}, z.core.$strip>;
id: z.ZodString;
name: z.ZodString;
address: z.ZodString;
displayName: z.ZodString;
enabled: z.ZodBoolean;
isDefault: z.ZodBoolean;
pollIntervalSec: z.ZodNumber;
channelId: z.ZodNullable<z.ZodString>;
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
secretBound: z.ZodBoolean;
status: z.ZodObject<{
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
lastPollError: z.ZodNullable<z.ZodString>;
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
cursor: z.ZodNullable<z.ZodString>;
polling: z.ZodBoolean;
}, z.core.$strip>;
createdAt: z.ZodISODateTime;
updatedAt: z.ZodISODateTime;
kind: z.ZodLiteral<"wecom">;
}, z.core.$strip>], "kind">;
};
/** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */
export declare const updateMailAccountRoute: {
readonly method: "PUT";
readonly path: "/api/gov/mail/accounts/:accountId";
readonly access: "ops";
readonly params: z.ZodObject<{
accountId: z.ZodString;
}, z.core.$strip>;
readonly body: z.ZodDiscriminatedUnion<[z.ZodObject<{
settings: z.ZodPrefault<z.ZodObject<{
imapHost: z.ZodDefault<z.ZodString>;
imapPort: z.ZodDefault<z.ZodNumber>;
imapTls: z.ZodDefault<z.ZodBoolean>;
smtpHost: z.ZodDefault<z.ZodString>;
smtpPort: z.ZodDefault<z.ZodNumber>;
smtpTls: z.ZodDefault<z.ZodBoolean>;
login: z.ZodOptional<z.ZodString>;
folder: z.ZodDefault<z.ZodString>;
}, z.core.$strip>>;
secret: z.ZodOptional<z.ZodObject<{
password: z.ZodString;
}, z.core.$strip>>;
name: z.ZodString;
displayName: z.ZodDefault<z.ZodString>;
enabled: z.ZodDefault<z.ZodBoolean>;
isDefault: z.ZodDefault<z.ZodBoolean>;
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
kind: z.ZodLiteral<"imap-smtp">;
address: z.ZodEmail;
}, z.core.$strip>, z.ZodObject<{
settings: z.ZodObject<{
corpId: z.ZodString;
apiBase: z.ZodDefault<z.ZodURL>;
}, z.core.$strip>;
secret: z.ZodOptional<z.ZodObject<{
corpSecret: z.ZodString;
}, z.core.$strip>>;
name: z.ZodString;
displayName: z.ZodDefault<z.ZodString>;
enabled: z.ZodDefault<z.ZodBoolean>;
isDefault: z.ZodDefault<z.ZodBoolean>;
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
kind: z.ZodLiteral<"wecom">;
}, z.core.$strip>], "kind">;
readonly output: z.ZodDiscriminatedUnion<[z.ZodObject<{
settings: z.ZodObject<{
imapHost: z.ZodDefault<z.ZodString>;
imapPort: z.ZodDefault<z.ZodNumber>;
imapTls: z.ZodDefault<z.ZodBoolean>;
smtpHost: z.ZodDefault<z.ZodString>;
smtpPort: z.ZodDefault<z.ZodNumber>;
smtpTls: z.ZodDefault<z.ZodBoolean>;
login: z.ZodOptional<z.ZodString>;
folder: z.ZodDefault<z.ZodString>;
}, z.core.$strip>;
id: z.ZodString;
name: z.ZodString;
address: z.ZodString;
displayName: z.ZodString;
enabled: z.ZodBoolean;
isDefault: z.ZodBoolean;
pollIntervalSec: z.ZodNumber;
channelId: z.ZodNullable<z.ZodString>;
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
secretBound: z.ZodBoolean;
status: z.ZodObject<{
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
lastPollError: z.ZodNullable<z.ZodString>;
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
cursor: z.ZodNullable<z.ZodString>;
polling: z.ZodBoolean;
}, z.core.$strip>;
createdAt: z.ZodISODateTime;
updatedAt: z.ZodISODateTime;
kind: z.ZodLiteral<"imap-smtp">;
}, z.core.$strip>, z.ZodObject<{
settings: z.ZodObject<{
corpId: z.ZodString;
apiBase: z.ZodDefault<z.ZodURL>;
}, z.core.$strip>;
id: z.ZodString;
name: z.ZodString;
address: z.ZodString;
displayName: z.ZodString;
enabled: z.ZodBoolean;
isDefault: z.ZodBoolean;
pollIntervalSec: z.ZodNumber;
channelId: z.ZodNullable<z.ZodString>;
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
secretBound: z.ZodBoolean;
status: z.ZodObject<{
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
lastPollError: z.ZodNullable<z.ZodString>;
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
cursor: z.ZodNullable<z.ZodString>;
polling: z.ZodBoolean;
}, z.core.$strip>;
createdAt: z.ZodISODateTime;
updatedAt: z.ZodISODateTime;
kind: z.ZodLiteral<"wecom">;
}, z.core.$strip>], "kind">;
};
export declare const deleteMailAccountRoute: {
readonly method: "DELETE";
readonly path: "/api/gov/mail/accounts/:accountId";
readonly access: "ops";
readonly params: z.ZodObject<{
accountId: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodObject<{
ok: z.ZodLiteral<true>;
}, z.core.$strip>;
};
/** 已存账号探测(用库里凭证);凭证空 412 */
export declare const probeMailAccountRoute: {
readonly method: "POST";
readonly path: "/api/gov/mail/accounts/:accountId/probe";
readonly access: "ops";
readonly params: z.ZodObject<{
accountId: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodObject<{
kind: z.ZodEnum<{
"imap-smtp": "imap-smtp";
wecom: "wecom";
}>;
address: z.ZodOptional<z.ZodString>;
receive: z.ZodObject<{
ok: z.ZodBoolean;
error: z.ZodOptional<z.ZodString>;
latencyMs: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>;
send: z.ZodObject<{
checked: z.ZodBoolean;
ok: z.ZodOptional<z.ZodBoolean>;
error: z.ZodOptional<z.ZodString>;
note: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
}, z.core.$strip>;
};
/** 立即拉一轮;另一进程占着租约 409;停用 412 */
export declare const pollMailAccountRoute: {
readonly method: "POST";
readonly path: "/api/gov/mail/accounts/:accountId/poll";
readonly access: "ops";
readonly params: z.ZodObject<{
accountId: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodObject<{
accountId: z.ZodString;
pulled: z.ZodNumber;
stored: z.ZodNumber;
dispatched: z.ZodNumber;
more: z.ZodBoolean;
error: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
};
export declare const sendTestMailRoute: {
readonly method: "POST";
readonly path: "/api/gov/mail/accounts/:accountId/send-test";
readonly access: "ops";
readonly params: z.ZodObject<{
accountId: z.ZodString;
}, z.core.$strip>;
readonly body: z.ZodObject<{
to: z.ZodEmail;
subject: z.ZodString;
text: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodObject<{
outboundId: z.ZodString;
accountId: z.ZodString;
messageId: z.ZodString;
state: z.ZodEnum<{
failed: "failed";
sent: "sent";
}>;
error: z.ZodOptional<z.ZodString>;
providerRef: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
};
export declare const listMailInboundRoute: {
readonly method: "GET";
readonly path: "/api/gov/mail/inbound";
readonly access: "ops";
readonly query: z.ZodObject<{
accountId: z.ZodOptional<z.ZodString>;
limit: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
mails: z.ZodArray<z.ZodObject<{
id: z.ZodString;
accountId: z.ZodString;
messageId: z.ZodString;
from: z.ZodObject<{
address: z.ZodString;
name: z.ZodString;
}, z.core.$strip>;
to: z.ZodArray<z.ZodObject<{
address: z.ZodString;
name: z.ZodString;
}, z.core.$strip>>;
cc: z.ZodArray<z.ZodObject<{
address: z.ZodString;
name: z.ZodString;
}, z.core.$strip>>;
subject: z.ZodString;
sentAt: z.ZodNullable<z.ZodISODateTime>;
receivedAt: z.ZodISODateTime;
textPreview: z.ZodString;
hasHtml: z.ZodBoolean;
attachments: z.ZodArray<z.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
ref: z.ZodNullable<z.ZodString>;
note: z.ZodString;
}, z.core.$strip>>;
deliveries: z.ZodArray<z.ZodObject<{
subscriber: z.ZodString;
state: z.ZodEnum<{
failed: "failed";
done: "done";
dispatching: "dispatching";
skipped: "skipped";
}>;
error: z.ZodNullable<z.ZodString>;
startedAt: z.ZodISODateTime;
finishedAt: z.ZodNullable<z.ZodISODateTime>;
}, z.core.$strip>>;
}, z.core.$strip>>;
}, z.core.$strip>;
};
export declare const getMailInboundRoute: {
readonly method: "GET";
readonly path: "/api/gov/mail/inbound/:mailId";
readonly access: "ops";
readonly params: z.ZodObject<{
mailId: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodObject<{
id: z.ZodString;
accountId: z.ZodString;
messageId: z.ZodString;
from: z.ZodObject<{
address: z.ZodString;
name: z.ZodString;
}, z.core.$strip>;
to: z.ZodArray<z.ZodObject<{
address: z.ZodString;
name: z.ZodString;
}, z.core.$strip>>;
cc: z.ZodArray<z.ZodObject<{
address: z.ZodString;
name: z.ZodString;
}, z.core.$strip>>;
subject: z.ZodString;
sentAt: z.ZodNullable<z.ZodISODateTime>;
receivedAt: z.ZodISODateTime;
textPreview: z.ZodString;
hasHtml: z.ZodBoolean;
attachments: z.ZodArray<z.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
ref: z.ZodNullable<z.ZodString>;
note: z.ZodString;
}, z.core.$strip>>;
deliveries: z.ZodArray<z.ZodObject<{
subscriber: z.ZodString;
state: z.ZodEnum<{
failed: "failed";
done: "done";
dispatching: "dispatching";
skipped: "skipped";
}>;
error: z.ZodNullable<z.ZodString>;
startedAt: z.ZodISODateTime;
finishedAt: z.ZodNullable<z.ZodISODateTime>;
}, z.core.$strip>>;
inReplyTo: z.ZodNullable<z.ZodString>;
references: z.ZodArray<z.ZodString>;
text: z.ZodNullable<z.ZodString>;
html: z.ZodNullable<z.ZodString>;
size: z.ZodNumber;
rawAvailable: z.ZodBoolean;
}, z.core.$strip>;
};
/** 附件原件(JSON + base64,与 /api/gov/upload/:id 同形);可带 ?ticket= 下载(ADR-012 §6) */
export declare const mailAttachmentRoute: {
readonly method: "GET";
readonly path: "/api/gov/mail/inbound/:mailId/attachments/:index";
readonly access: "ops";
readonly params: z.ZodObject<{
mailId: z.ZodString;
index: z.ZodCoercedNumber<unknown>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
name: z.ZodString;
contentType: z.ZodString;
dataBase64: z.ZodString;
}, z.core.$strip>;
};
/** 原件 .eml;超限没存 404 */
export declare const mailRawRoute: {
readonly method: "GET";
readonly path: "/api/gov/mail/inbound/:mailId/raw";
readonly access: "ops";
readonly params: z.ZodObject<{
mailId: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodObject<{
name: z.ZodString;
contentType: z.ZodLiteral<"message/rfc822">;
dataBase64: z.ZodString;
}, z.core.$strip>;
};
/** 人按的钮:重新投给订阅方(缺省全部);从此是至少一次 */
export declare const redeliverMailInboundRoute: {
readonly method: "POST";
readonly path: "/api/gov/mail/inbound/:mailId/redeliver";
readonly access: "ops";
readonly params: z.ZodObject<{
mailId: z.ZodString;
}, z.core.$strip>;
readonly body: z.ZodDefault<z.ZodObject<{
subscriber: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
readonly output: z.ZodObject<{
deliveries: z.ZodArray<z.ZodObject<{
subscriber: z.ZodString;
state: z.ZodEnum<{
failed: "failed";
done: "done";
dispatching: "dispatching";
skipped: "skipped";
}>;
error: z.ZodNullable<z.ZodString>;
startedAt: z.ZodISODateTime;
finishedAt: z.ZodNullable<z.ZodISODateTime>;
}, z.core.$strip>>;
}, z.core.$strip>;
};
export declare const listMailOutboundRoute: {
readonly method: "GET";
readonly path: "/api/gov/mail/outbound";
readonly access: "ops";
readonly query: z.ZodObject<{
accountId: z.ZodOptional<z.ZodString>;
limit: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
mails: z.ZodArray<z.ZodObject<{
id: z.ZodString;
accountId: z.ZodString;
messageId: z.ZodString;
to: z.ZodArray<z.ZodString>;
cc: z.ZodArray<z.ZodString>;
bcc: z.ZodArray<z.ZodString>;
subject: z.ZodString;
state: z.ZodEnum<{
failed: "failed";
sending: "sending";
sent: "sent";
}>;
error: z.ZodNullable<z.ZodString>;
providerRef: z.ZodNullable<z.ZodString>;
requestedBy: z.ZodRecord<z.ZodString, z.ZodUnknown>;
createdAt: z.ZodISODateTime;
sentAt: z.ZodNullable<z.ZodISODateTime>;
}, z.core.$strip>>;
}, z.core.$strip>;
};
/** 全部 15 条 —— 文档总表与覆盖测试按它数 */
export declare const MAIL_ROUTES: readonly RouteContract[];
+79 -6
View File
@@ -3,20 +3,23 @@
* 工作台五要素:任务 / 审批 / 消息 / 纪要 / 汇报 —— 同一份过程事实的五种投影。
*/
import { z } from 'zod';
/** 我是谁 —— 无会话即 401,前端据此落登录页(ADR-010) */
/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */
export declare const sessionRoute: {
readonly method: "GET";
readonly path: "/api/session";
readonly access: "public";
readonly output: z.ZodObject<{
staff: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
account: z.ZodString;
name: z.ZodString;
email: z.ZodOptional<z.ZodString>;
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
state: z.ZodDefault<z.ZodEnum<{
active: "active";
disabled: "disabled";
}>>;
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>;
position: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
@@ -56,13 +59,11 @@ export declare const sessionRoute: {
}, z.core.$strip>>;
}, z.core.$strip>;
};
/**
* 登录 —— 单组织 cookie session(编码规范 §4 / ADR-010)。
* 成功即种 `jcai_session`(HttpOnly);失败只回一句话,不区分账号不存在还是口令错。
*/
/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */
export declare const loginRoute: {
readonly method: "POST";
readonly path: "/api/login";
readonly access: "public";
readonly body: z.ZodObject<{
account: z.ZodString;
password: z.ZodString;
@@ -72,11 +73,13 @@ export declare const loginRoute: {
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
account: z.ZodString;
name: z.ZodString;
email: z.ZodOptional<z.ZodString>;
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
state: z.ZodDefault<z.ZodEnum<{
active: "active";
disabled: "disabled";
}>>;
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>;
position: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
@@ -116,10 +119,11 @@ export declare const loginRoute: {
}, z.core.$strip>>;
}, z.core.$strip>;
};
/** 登出 —— 服务端会话立刻作废,不等 cookie 到期 */
/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */
export declare const logoutRoute: {
readonly method: "POST";
readonly path: "/api/logout";
readonly access: "public";
readonly output: z.ZodObject<{
ok: z.ZodBoolean;
}, z.core.$strip>;
@@ -127,6 +131,7 @@ export declare const logoutRoute: {
export declare const healthRoute: {
readonly method: "GET";
readonly path: "/api/health";
readonly access: "public";
readonly output: z.ZodObject<{
ok: z.ZodBoolean;
version: z.ZodString;
@@ -136,6 +141,51 @@ export declare const healthRoute: {
}>;
}, z.core.$strip>;
};
/** 我是谁、我能进哪几个端(ADR-012)—— 中间件解好 principal 放进上下文,这里只回投影 */
export declare const meRoute: {
readonly method: "GET";
readonly path: "/api/me";
readonly access: "any";
readonly output: z.ZodObject<{
staff: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
account: z.ZodString;
name: z.ZodString;
email: z.ZodOptional<z.ZodString>;
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
state: z.ZodDefault<z.ZodEnum<{
active: "active";
disabled: "disabled";
}>>;
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>;
position: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
code: z.ZodString;
name: z.ZodString;
note: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
roles: z.ZodArray<z.ZodString>;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
}, z.core.$strip>;
};
/**
* 60 秒票据(ADR-012 §9.3)—— 只为两条带不了 Authorization 头的路:
* `EventSource('/api/stream')` 与 `<a href="…&raw=1" download>`。GET 上 `?ticket=` 认它。
*/
export declare const authTicketRoute: {
readonly method: "GET";
readonly path: "/api/auth/ticket";
readonly access: "any";
readonly output: z.ZodObject<{
ticket: z.ZodString;
expiresAt: z.ZodString;
}, z.core.$strip>;
};
/** 任务卡 —— 清单里一条 = 一个活动的一次运行,不是一整条工作流 */
export declare const taskCardSchema: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
@@ -177,6 +227,7 @@ export type TaskCard = z.infer<typeof taskCardSchema>;
export declare const listTasksRoute: {
readonly method: "GET";
readonly path: "/api/desk/tasks";
readonly access: "workbench";
readonly output: z.ZodObject<{
tasks: z.ZodArray<z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
@@ -2138,6 +2189,7 @@ export type TaskDetail = z.infer<typeof taskDetailSchema>;
export declare const taskDetailRoute: {
readonly method: "GET";
readonly path: "/api/desk/tasks/:taskId";
readonly access: "workbench";
readonly params: z.ZodObject<{
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
}, z.core.$strip>;
@@ -4043,6 +4095,7 @@ export declare const taskDetailRoute: {
export declare const replyWorkOrderRoute: {
readonly method: "POST";
readonly path: "/api/desk/work-orders/reply";
readonly access: "workbench";
readonly body: z.ZodObject<{
reply: z.ZodRecord<z.ZodString, z.ZodUnknown>;
}, z.core.$strip>;
@@ -4058,6 +4111,7 @@ export declare const replyWorkOrderRoute: {
export declare const replyChatCardRoute: {
readonly method: "POST";
readonly path: "/api/desk/chat-cards/reply";
readonly access: "workbench";
readonly body: z.ZodObject<{
reply: z.ZodRecord<z.ZodString, z.ZodUnknown>;
}, z.core.$strip>;
@@ -4072,6 +4126,7 @@ export declare const replyChatCardRoute: {
export declare const markCommReadRoute: {
readonly method: "POST";
readonly path: "/api/desk/comms/:commId/read";
readonly access: "workbench";
readonly params: z.ZodObject<{
commId: z.ZodString;
}, z.core.$strip>;
@@ -4120,6 +4175,7 @@ export declare const markCommReadRoute: {
export declare const saveTableRoute: {
readonly method: "POST";
readonly path: "/api/desk/tasks/:taskId/table";
readonly access: "workbench";
readonly params: z.ZodObject<{
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
}, z.core.$strip>;
@@ -4149,6 +4205,7 @@ export declare const saveTableRoute: {
export declare const openAssetFileRoute: {
readonly method: "GET";
readonly path: "/api/desk/tasks/:taskId/file";
readonly access: "workbench";
readonly params: z.ZodObject<{
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
}, z.core.$strip>;
@@ -4199,6 +4256,7 @@ export declare const openAssetFileRoute: {
export declare const sayRoute: {
readonly method: "POST";
readonly path: "/api/desk/tasks/:taskId/say";
readonly access: "workbench";
readonly params: z.ZodObject<{
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
}, z.core.$strip>;
@@ -4393,6 +4451,7 @@ export type SystemMessageView = z.infer<typeof systemMessageSchema>;
export declare const listMessagesRoute: {
readonly method: "GET";
readonly path: "/api/desk/messages";
readonly access: "workbench";
readonly output: z.ZodObject<{
messages: z.ZodArray<z.ZodObject<{
id: z.ZodString;
@@ -4448,6 +4507,7 @@ export declare const listMessagesRoute: {
export declare const markMessageReadRoute: {
readonly method: "POST";
readonly path: "/api/desk/messages/:messageId/read";
readonly access: "workbench";
readonly params: z.ZodObject<{
messageId: z.ZodString;
}, z.core.$strip>;
@@ -4459,6 +4519,7 @@ export declare const markMessageReadRoute: {
export declare const answerMessageRoute: {
readonly method: "POST";
readonly path: "/api/desk/messages/:messageId/answer";
readonly access: "workbench";
readonly params: z.ZodObject<{
messageId: z.ZodString;
}, z.core.$strip>;
@@ -4473,6 +4534,7 @@ export declare const answerMessageRoute: {
export declare const confirmTriggerPendingRoute: {
readonly method: "POST";
readonly path: "/api/desk/trigger-pending/:id/confirm";
readonly access: "workbench";
readonly params: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
@@ -4521,6 +4583,7 @@ export type MinuteView = z.infer<typeof minuteSchema>;
export declare const listMinutesRoute: {
readonly method: "GET";
readonly path: "/api/minutes";
readonly access: "workbench";
readonly output: z.ZodObject<{
minutes: z.ZodArray<z.ZodObject<{
id: z.ZodString;
@@ -4550,6 +4613,7 @@ export declare const listMinutesRoute: {
export declare const createMinuteRoute: {
readonly method: "POST";
readonly path: "/api/minutes";
readonly access: "workbench";
readonly body: z.ZodObject<{
title: z.ZodString;
body: z.ZodString;
@@ -4587,6 +4651,7 @@ export declare const createMinuteRoute: {
export declare const appendResolutionRoute: {
readonly method: "POST";
readonly path: "/api/minutes/:minuteId/resolutions";
readonly access: "workbench";
readonly params: z.ZodObject<{
minuteId: z.ZodString;
}, z.core.$strip>;
@@ -4622,6 +4687,7 @@ export declare const appendResolutionRoute: {
export declare const registerResolutionRoute: {
readonly method: "POST";
readonly path: "/api/minutes/resolutions/:resolutionId/register";
readonly access: "workbench";
readonly params: z.ZodObject<{
resolutionId: z.ZodString;
}, z.core.$strip>;
@@ -4704,6 +4770,7 @@ export type ReportView = z.infer<typeof reportSchema>;
export declare const draftReportRoute: {
readonly method: "GET";
readonly path: "/api/reports/:period";
readonly access: "workbench";
readonly params: z.ZodObject<{
period: z.ZodEnum<{
day: "day";
@@ -4759,6 +4826,7 @@ export declare const draftReportRoute: {
export declare const listReportsRoute: {
readonly method: "GET";
readonly path: "/api/reports";
readonly access: "workbench";
readonly output: z.ZodObject<{
reports: z.ZodArray<z.ZodObject<{
id: z.ZodString;
@@ -4809,6 +4877,7 @@ export declare const listReportsRoute: {
export declare const submitReportRoute: {
readonly method: "POST";
readonly path: "/api/reports/:reportId/submit";
readonly access: "workbench";
readonly params: z.ZodObject<{
reportId: z.ZodString;
}, z.core.$strip>;
@@ -4884,6 +4953,7 @@ export type ApprovalItem = z.infer<typeof approvalItemSchema>;
export declare const listApprovalsForMeRoute: {
readonly method: "GET";
readonly path: "/api/approvals";
readonly access: "workbench";
readonly output: z.ZodObject<{
approvals: z.ZodArray<z.ZodObject<{
id: z.ZodString;
@@ -4910,6 +4980,7 @@ export declare const listApprovalsForMeRoute: {
export declare const decideApprovalRoute: {
readonly method: "POST";
readonly path: "/api/approvals/:workflowId/:nodeId/decide";
readonly access: "workbench";
readonly params: z.ZodObject<{
workflowId: z.ZodString;
nodeId: z.ZodString;
@@ -4940,6 +5011,7 @@ export type ListApprovalsForMeOutput = z.infer<typeof listApprovalsForMeRoute.ou
export declare const listRegistrationTriggersRoute: {
readonly method: "GET";
readonly path: "/api/triggers/registration";
readonly access: "workbench";
readonly output: z.ZodObject<{
triggers: z.ZodArray<z.ZodObject<{
id: z.ZodString;
@@ -4953,6 +5025,7 @@ export declare const listRegistrationTriggersRoute: {
export declare const fireRegistrationRoute: {
readonly method: "POST";
readonly path: "/api/triggers/:triggerId/register";
readonly access: "workbench";
readonly params: z.ZodObject<{
triggerId: z.ZodString;
}, z.core.$strip>;
+1
View File
@@ -13,4 +13,5 @@ export * from './ledger.ts';
export * from './process-package.ts';
export * from './manifest.ts';
export * from './org.ts';
export * from './mail.ts';
export * from './api/index.ts';
+364
View File
@@ -0,0 +1,364 @@
/**
* 邮件收发的契约(设计稿 docs/superpowers/specs/2026-08-25-mail-design.md §7.1;ADR-013)。
*
* 只有 zod schema 与推导类型。账号视图**永不含密文**(ADR-009 §4):只回 `secretBound`。
* 入参按 `kind` 分两形 —— 标准协议(IMAP/SMTP)与企业微信邮件 API(应用邮箱),
* 两条路的共同点是「轮询拉 + MIME 解析」,腾讯没有带内容的推送。
*/
import { z } from 'zod';
export declare const MAIL_ACCOUNT_KINDS: readonly ["imap-smtp", "wecom"];
export declare const mailAccountKindSchema: z.ZodEnum<{
"imap-smtp": "imap-smtp";
wecom: "wecom";
}>;
export type MailAccountKind = z.infer<typeof mailAccountKindSchema>;
export declare const MAIL_ACCOUNT_KIND_LABEL: Readonly<Record<MailAccountKind, string>>;
/** 标准协议:默认值就是腾讯企业邮箱官方帮助给的服务器与 SSL 端口 */
export declare const imapSmtpSettingsSchema: z.ZodObject<{
imapHost: z.ZodDefault<z.ZodString>;
imapPort: z.ZodDefault<z.ZodNumber>;
imapTls: z.ZodDefault<z.ZodBoolean>;
smtpHost: z.ZodDefault<z.ZodString>;
smtpPort: z.ZodDefault<z.ZodNumber>;
smtpTls: z.ZodDefault<z.ZodBoolean>;
login: z.ZodOptional<z.ZodString>;
folder: z.ZodDefault<z.ZodString>;
}, z.core.$strip>;
export type ImapSmtpSettings = z.infer<typeof imapSmtpSettingsSchema>;
/** 企业微信邮件 API:corpid + 自建应用 secret(密封,不在 settings 里) */
export declare const wecomSettingsSchema: z.ZodObject<{
corpId: z.ZodString;
apiBase: z.ZodDefault<z.ZodURL>;
}, z.core.$strip>;
export type WecomSettings = z.infer<typeof wecomSettingsSchema>;
export declare const mailAccountInputSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
settings: z.ZodPrefault<z.ZodObject<{
imapHost: z.ZodDefault<z.ZodString>;
imapPort: z.ZodDefault<z.ZodNumber>;
imapTls: z.ZodDefault<z.ZodBoolean>;
smtpHost: z.ZodDefault<z.ZodString>;
smtpPort: z.ZodDefault<z.ZodNumber>;
smtpTls: z.ZodDefault<z.ZodBoolean>;
login: z.ZodOptional<z.ZodString>;
folder: z.ZodDefault<z.ZodString>;
}, z.core.$strip>>;
secret: z.ZodOptional<z.ZodObject<{
password: z.ZodString;
}, z.core.$strip>>;
name: z.ZodString;
displayName: z.ZodDefault<z.ZodString>;
enabled: z.ZodDefault<z.ZodBoolean>;
isDefault: z.ZodDefault<z.ZodBoolean>;
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
kind: z.ZodLiteral<"imap-smtp">;
address: z.ZodEmail;
}, z.core.$strip>, z.ZodObject<{
settings: z.ZodObject<{
corpId: z.ZodString;
apiBase: z.ZodDefault<z.ZodURL>;
}, z.core.$strip>;
secret: z.ZodOptional<z.ZodObject<{
corpSecret: z.ZodString;
}, z.core.$strip>>;
name: z.ZodString;
displayName: z.ZodDefault<z.ZodString>;
enabled: z.ZodDefault<z.ZodBoolean>;
isDefault: z.ZodDefault<z.ZodBoolean>;
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
kind: z.ZodLiteral<"wecom">;
}, z.core.$strip>], "kind">;
export type MailAccountInput = z.infer<typeof mailAccountInputSchema>;
export declare const mailAccountStatusSchema: z.ZodObject<{
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
lastPollError: z.ZodNullable<z.ZodString>;
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
cursor: z.ZodNullable<z.ZodString>;
polling: z.ZodBoolean;
}, z.core.$strip>;
export declare const mailAccountViewSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
settings: z.ZodObject<{
imapHost: z.ZodDefault<z.ZodString>;
imapPort: z.ZodDefault<z.ZodNumber>;
imapTls: z.ZodDefault<z.ZodBoolean>;
smtpHost: z.ZodDefault<z.ZodString>;
smtpPort: z.ZodDefault<z.ZodNumber>;
smtpTls: z.ZodDefault<z.ZodBoolean>;
login: z.ZodOptional<z.ZodString>;
folder: z.ZodDefault<z.ZodString>;
}, z.core.$strip>;
id: z.ZodString;
name: z.ZodString;
address: z.ZodString;
displayName: z.ZodString;
enabled: z.ZodBoolean;
isDefault: z.ZodBoolean;
pollIntervalSec: z.ZodNumber;
channelId: z.ZodNullable<z.ZodString>;
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
secretBound: z.ZodBoolean;
status: z.ZodObject<{
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
lastPollError: z.ZodNullable<z.ZodString>;
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
cursor: z.ZodNullable<z.ZodString>;
polling: z.ZodBoolean;
}, z.core.$strip>;
createdAt: z.ZodISODateTime;
updatedAt: z.ZodISODateTime;
kind: z.ZodLiteral<"imap-smtp">;
}, z.core.$strip>, z.ZodObject<{
settings: z.ZodObject<{
corpId: z.ZodString;
apiBase: z.ZodDefault<z.ZodURL>;
}, z.core.$strip>;
id: z.ZodString;
name: z.ZodString;
address: z.ZodString;
displayName: z.ZodString;
enabled: z.ZodBoolean;
isDefault: z.ZodBoolean;
pollIntervalSec: z.ZodNumber;
channelId: z.ZodNullable<z.ZodString>;
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
secretBound: z.ZodBoolean;
status: z.ZodObject<{
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
lastPollError: z.ZodNullable<z.ZodString>;
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
cursor: z.ZodNullable<z.ZodString>;
polling: z.ZodBoolean;
}, z.core.$strip>;
createdAt: z.ZodISODateTime;
updatedAt: z.ZodISODateTime;
kind: z.ZodLiteral<"wecom">;
}, z.core.$strip>], "kind">;
export type MailAccountView = z.infer<typeof mailAccountViewSchema>;
/** 探测报告:收 / 发各说各的;wecom 的发信一栏 `checked:false` —— 拿到 token 不等于能发 */
export declare const mailProbeReportSchema: z.ZodObject<{
kind: z.ZodEnum<{
"imap-smtp": "imap-smtp";
wecom: "wecom";
}>;
address: z.ZodOptional<z.ZodString>;
receive: z.ZodObject<{
ok: z.ZodBoolean;
error: z.ZodOptional<z.ZodString>;
latencyMs: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>;
send: z.ZodObject<{
checked: z.ZodBoolean;
ok: z.ZodOptional<z.ZodBoolean>;
error: z.ZodOptional<z.ZodString>;
note: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
}, z.core.$strip>;
export type MailProbeReport = z.infer<typeof mailProbeReportSchema>;
export declare const MAIL_DELIVERY_STATES: readonly ["dispatching", "done", "failed", "skipped"];
export declare const mailDeliveryStateSchema: z.ZodEnum<{
failed: "failed";
done: "done";
dispatching: "dispatching";
skipped: "skipped";
}>;
export type MailDeliveryState = z.infer<typeof mailDeliveryStateSchema>;
export declare const mailDeliveryViewSchema: z.ZodObject<{
subscriber: z.ZodString;
state: z.ZodEnum<{
failed: "failed";
done: "done";
dispatching: "dispatching";
skipped: "skipped";
}>;
error: z.ZodNullable<z.ZodString>;
startedAt: z.ZodISODateTime;
finishedAt: z.ZodNullable<z.ZodISODateTime>;
}, z.core.$strip>;
export type MailDeliveryView = z.infer<typeof mailDeliveryViewSchema>;
export declare const mailAddressSchema: z.ZodObject<{
address: z.ZodString;
name: z.ZodString;
}, z.core.$strip>;
export type MailAddress = z.infer<typeof mailAddressSchema>;
export declare const mailInboundAttachmentSchema: z.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
ref: z.ZodNullable<z.ZodString>;
note: z.ZodString;
}, z.core.$strip>;
export type MailInboundAttachment = z.infer<typeof mailInboundAttachmentSchema>;
export declare const mailInboundSummarySchema: z.ZodObject<{
id: z.ZodString;
accountId: z.ZodString;
messageId: z.ZodString;
from: z.ZodObject<{
address: z.ZodString;
name: z.ZodString;
}, z.core.$strip>;
to: z.ZodArray<z.ZodObject<{
address: z.ZodString;
name: z.ZodString;
}, z.core.$strip>>;
cc: z.ZodArray<z.ZodObject<{
address: z.ZodString;
name: z.ZodString;
}, z.core.$strip>>;
subject: z.ZodString;
sentAt: z.ZodNullable<z.ZodISODateTime>;
receivedAt: z.ZodISODateTime;
textPreview: z.ZodString;
hasHtml: z.ZodBoolean;
attachments: z.ZodArray<z.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
ref: z.ZodNullable<z.ZodString>;
note: z.ZodString;
}, z.core.$strip>>;
deliveries: z.ZodArray<z.ZodObject<{
subscriber: z.ZodString;
state: z.ZodEnum<{
failed: "failed";
done: "done";
dispatching: "dispatching";
skipped: "skipped";
}>;
error: z.ZodNullable<z.ZodString>;
startedAt: z.ZodISODateTime;
finishedAt: z.ZodNullable<z.ZodISODateTime>;
}, z.core.$strip>>;
}, z.core.$strip>;
export type MailInboundSummary = z.infer<typeof mailInboundSummarySchema>;
export declare const mailInboundDetailSchema: z.ZodObject<{
id: z.ZodString;
accountId: z.ZodString;
messageId: z.ZodString;
from: z.ZodObject<{
address: z.ZodString;
name: z.ZodString;
}, z.core.$strip>;
to: z.ZodArray<z.ZodObject<{
address: z.ZodString;
name: z.ZodString;
}, z.core.$strip>>;
cc: z.ZodArray<z.ZodObject<{
address: z.ZodString;
name: z.ZodString;
}, z.core.$strip>>;
subject: z.ZodString;
sentAt: z.ZodNullable<z.ZodISODateTime>;
receivedAt: z.ZodISODateTime;
textPreview: z.ZodString;
hasHtml: z.ZodBoolean;
attachments: z.ZodArray<z.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
ref: z.ZodNullable<z.ZodString>;
note: z.ZodString;
}, z.core.$strip>>;
deliveries: z.ZodArray<z.ZodObject<{
subscriber: z.ZodString;
state: z.ZodEnum<{
failed: "failed";
done: "done";
dispatching: "dispatching";
skipped: "skipped";
}>;
error: z.ZodNullable<z.ZodString>;
startedAt: z.ZodISODateTime;
finishedAt: z.ZodNullable<z.ZodISODateTime>;
}, z.core.$strip>>;
inReplyTo: z.ZodNullable<z.ZodString>;
references: z.ZodArray<z.ZodString>;
text: z.ZodNullable<z.ZodString>;
html: z.ZodNullable<z.ZodString>;
size: z.ZodNumber;
rawAvailable: z.ZodBoolean;
}, z.core.$strip>;
export type MailInboundDetail = z.infer<typeof mailInboundDetailSchema>;
export declare const mailSendInputSchema: z.ZodObject<{
accountId: z.ZodOptional<z.ZodString>;
from: z.ZodOptional<z.ZodString>;
to: z.ZodArray<z.ZodEmail>;
cc: z.ZodDefault<z.ZodArray<z.ZodEmail>>;
bcc: z.ZodDefault<z.ZodArray<z.ZodEmail>>;
subject: z.ZodString;
text: z.ZodOptional<z.ZodString>;
html: z.ZodOptional<z.ZodString>;
inReplyTo: z.ZodOptional<z.ZodString>;
references: z.ZodDefault<z.ZodArray<z.ZodString>>;
attachments: z.ZodDefault<z.ZodArray<z.ZodObject<{
name: z.ZodString;
contentType: z.ZodDefault<z.ZodString>;
dataBase64: z.ZodOptional<z.ZodString>;
uploadRef: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>>;
idempotencyKey: z.ZodOptional<z.ZodString>;
requestedBy: z.ZodObject<{
kind: z.ZodEnum<{
channel: "channel";
tool: "tool";
ops: "ops";
}>;
taskId: z.ZodOptional<z.ZodString>;
staffId: z.ZodOptional<z.ZodString>;
tool: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
}, z.core.$strip>;
export type MailSendInput = z.infer<typeof mailSendInputSchema>;
export declare const MAIL_OUTBOUND_STATES: readonly ["sending", "sent", "failed"];
export declare const mailOutboundStateSchema: z.ZodEnum<{
failed: "failed";
sending: "sending";
sent: "sent";
}>;
export type MailOutboundState = z.infer<typeof mailOutboundStateSchema>;
export declare const mailSendResultSchema: z.ZodObject<{
outboundId: z.ZodString;
accountId: z.ZodString;
messageId: z.ZodString;
state: z.ZodEnum<{
failed: "failed";
sent: "sent";
}>;
error: z.ZodOptional<z.ZodString>;
providerRef: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
export type MailSendResult = z.infer<typeof mailSendResultSchema>;
export declare const mailOutboundViewSchema: z.ZodObject<{
id: z.ZodString;
accountId: z.ZodString;
messageId: z.ZodString;
to: z.ZodArray<z.ZodString>;
cc: z.ZodArray<z.ZodString>;
bcc: z.ZodArray<z.ZodString>;
subject: z.ZodString;
state: z.ZodEnum<{
failed: "failed";
sending: "sending";
sent: "sent";
}>;
error: z.ZodNullable<z.ZodString>;
providerRef: z.ZodNullable<z.ZodString>;
requestedBy: z.ZodRecord<z.ZodString, z.ZodUnknown>;
createdAt: z.ZodISODateTime;
sentAt: z.ZodNullable<z.ZodISODateTime>;
}, z.core.$strip>;
export type MailOutboundView = z.infer<typeof mailOutboundViewSchema>;
export declare const mailPollOutcomeSchema: z.ZodObject<{
accountId: z.ZodString;
pulled: z.ZodNumber;
stored: z.ZodNumber;
dispatched: z.ZodNumber;
more: z.ZodBoolean;
error: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
export type MailPollOutcome = z.infer<typeof mailPollOutcomeSchema>;
+115 -23
View File
@@ -1,9 +1,9 @@
/**
* 员工、岗位与权限(编码规范 §4 · `org`;语义出处 OS 02 员工管理)。
* 员工、岗位与端(编码规范 §4 · `org`;语义出处 OS 02 员工管理;鉴权归统一鉴权中心,ADR-012)。
*
* 权限按岗位授予:客体三类(数据 域.对象[.字段] / 治理功能 / 任务)× 动作三种(读 / 写 / 治理)。
* 任务负责人与被征答人对所涉内容**天然可见**,不另行授权。
* 单组织、≤50 人:不做多租户,不引 OAuth/OIDC(编码规范 §4 鉴权定调)。
* 身份来自 Keycloak:`Staff.id` 就是 token 的 `sub`。权限只到「端」(design / ops / workbench),
* 角色 → 端由运营中心的表定;岗位仍是本系统的表(流程引擎按它解析负责人)。
* 旧的岗位授权(客体三类 × 动作三种)与口令会话已退役 —— 类型留一轮(@deprecated,下个迭代删)。
*/
import { z } from 'zod';
export declare const positionSchema: z.ZodObject<{
@@ -23,23 +23,123 @@ export declare const staffSchema: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
account: z.ZodString;
name: z.ZodString;
email: z.ZodOptional<z.ZodString>;
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
state: z.ZodDefault<z.ZodEnum<{
active: "active";
disabled: "disabled";
}>>;
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>;
export type Staff = z.infer<typeof staffSchema>;
export declare const principalSchema: z.ZodObject<{
staff: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
account: z.ZodString;
name: z.ZodString;
email: z.ZodOptional<z.ZodString>;
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
state: z.ZodDefault<z.ZodEnum<{
active: "active";
disabled: "disabled";
}>>;
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>;
position: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
code: z.ZodString;
name: z.ZodString;
note: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
roles: z.ZodArray<z.ZodString>;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
}, z.core.$strip>;
export type Principal = z.infer<typeof principalSchema>;
/** 角色 → 端:一行一个 realm 角色 */
export declare const roleAccessSchema: z.ZodObject<{
role: z.ZodString;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
}, z.core.$strip>;
export type RoleAccess = z.infer<typeof roleAccessSchema>;
/**
* 名册同步的结果 —— 没配 provisioning 就如实说没配,不摆零。
* 三个计数说的是**真落进库里的条数**,不是打算写几条:单条写失败时它不进计数,
* 而是点名进 `failed` —— 一次半途失败的同步不许报成一排绿数字。
*/
export declare const rosterSyncOutcomeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
configured: z.ZodLiteral<false>;
message: z.ZodString;
}, z.core.$strip>, z.ZodObject<{
configured: z.ZodLiteral<true>;
inserted: z.ZodNumber;
updated: z.ZodNumber;
disabled: z.ZodNumber;
total: z.ZodNumber;
failed: z.ZodArray<z.ZodObject<{
account: z.ZodString;
reason: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>], "configured">;
export type RosterSyncOutcome = z.infer<typeof rosterSyncOutcomeSchema>;
export declare const inFlightWorkSchema: z.ZodObject<{
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
blocked: z.ZodBoolean;
openTasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
taskId: z.ZodString;
title: z.ZodString;
}, z.core.$strip>>>;
openCards: z.ZodDefault<z.ZodArray<z.ZodObject<{
cardId: z.ZodString;
title: z.ZodString;
}, z.core.$strip>>>;
openApprovals: z.ZodDefault<z.ZodArray<z.ZodObject<{
approvalId: z.ZodString;
title: z.ZodString;
}, z.core.$strip>>>;
}, z.core.$strip>;
export type InFlightWork = z.infer<typeof inFlightWorkSchema>;
/** @deprecated 随 ADR-012 改名 `inFlightWorkSchema`;下个迭代删 */
export declare const disableCheckSchema: z.ZodObject<{
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
blocked: z.ZodBoolean;
openTasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
taskId: z.ZodString;
title: z.ZodString;
}, z.core.$strip>>>;
openCards: z.ZodDefault<z.ZodArray<z.ZodObject<{
cardId: z.ZodString;
title: z.ZodString;
}, z.core.$strip>>>;
openApprovals: z.ZodDefault<z.ZodArray<z.ZodObject<{
approvalId: z.ZodString;
title: z.ZodString;
}, z.core.$strip>>>;
}, z.core.$strip>;
/** @deprecated 改用 `InFlightWork` */
export type DisableCheck = InFlightWork;
/** @deprecated 权限只到端,动作三分已退役 */
export declare const GRANT_ACTIONS: readonly ["read", "write", "govern"];
/** @deprecated */
export declare const grantActionSchema: z.ZodEnum<{
read: "read";
write: "write";
govern: "govern";
}>;
/** @deprecated */
export type GrantAction = z.infer<typeof grantActionSchema>;
/** @deprecated */
export declare const GRANT_ACTION_LABEL: Readonly<Record<GrantAction, string>>;
/** 治理台九功能 —— 授权客体之一 */
/** @deprecated 治理台九功能不再是授权客体 */
export declare const GOVERNANCE_FUNCTIONS: readonly ["task", "staff", "process", "trigger", "activity", "tool", "data", "memory", "system"];
/** @deprecated */
export declare const governanceFunctionSchema: z.ZodEnum<{
trigger: "trigger";
data: "data";
@@ -51,8 +151,11 @@ export declare const governanceFunctionSchema: z.ZodEnum<{
process: "process";
memory: "memory";
}>;
/** @deprecated */
export type GovernanceFunction = z.infer<typeof governanceFunctionSchema>;
/** @deprecated */
export declare const GOVERNANCE_FUNCTION_LABEL: Readonly<Record<GovernanceFunction, string>>;
/** @deprecated */
export declare const grantObjectSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"data">;
domain: z.ZodString;
@@ -75,7 +178,9 @@ export declare const grantObjectSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"task">;
activityId: z.ZodOptional<z.ZodString>;
}, z.core.$strip>], "kind">;
/** @deprecated */
export type GrantObject = z.infer<typeof grantObjectSchema>;
/** @deprecated */
export declare const grantSchema: z.ZodObject<{
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
object: z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -106,35 +211,21 @@ export declare const grantSchema: z.ZodObject<{
govern: "govern";
}>;
}, z.core.$strip>;
/** @deprecated */
export type Grant = z.infer<typeof grantSchema>;
/** 员工停用前,其在途任务与待答卡须先改派完毕 */
export declare const disableCheckSchema: z.ZodObject<{
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
blocked: z.ZodBoolean;
openTasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
taskId: z.ZodString;
title: z.ZodString;
}, z.core.$strip>>>;
openCards: z.ZodDefault<z.ZodArray<z.ZodObject<{
cardId: z.ZodString;
title: z.ZodString;
}, z.core.$strip>>>;
openApprovals: z.ZodDefault<z.ZodArray<z.ZodObject<{
approvalId: z.ZodString;
title: z.ZodString;
}, z.core.$strip>>>;
}, z.core.$strip>;
export type DisableCheck = z.infer<typeof disableCheckSchema>;
/** @deprecated 改用 `principalSchema` / `GET /api/me` */
export declare const sessionSchema: z.ZodObject<{
staff: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
account: z.ZodString;
name: z.ZodString;
email: z.ZodOptional<z.ZodString>;
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
state: z.ZodDefault<z.ZodEnum<{
active: "active";
disabled: "disabled";
}>>;
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>;
position: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
@@ -173,6 +264,7 @@ export declare const sessionSchema: z.ZodObject<{
}>;
}, z.core.$strip>>;
}, z.core.$strip>;
/** @deprecated 改用 `Principal` */
export type Session = z.infer<typeof sessionSchema>;
/**
* 工作组 —— 一组「岗位 → 员工」的绑定,起单时装载、随工作流走。
+5 -1
View File
@@ -12,7 +12,6 @@
"start": "node dist/main.js",
"seed": "node dist/seed.js",
"migrate": "node dist/migrate.js",
"set-password": "node dist/set-password.js",
"reset-runs": "node dist/reset-runs.js",
"install-activities": "node dist/install-activities.js"
},
@@ -26,11 +25,16 @@
"ajv-formats": "^3.0.1",
"drizzle-orm": "^0.45.2",
"hono": "^4.12.32",
"imapflow": "^1.7.6",
"jose": "^5.10.0",
"nodemailer": "^8.0.11",
"pg": "^8.22.0",
"pg-boss": "^12.26.3",
"postal-mime": "^3.0.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.20.0"
}
}
@@ -8,7 +8,7 @@
*/
import { describe, expect, it } from 'vitest';
import type { TaskId } from '@contracts';
import { archiveAssetFiles, keyText } from '../asset-archive.ts';
import { archiveAssetFiles, assetRowsOf, keyText } from '../asset-archive.ts';
const TASK = 't-1' as TaskId;
@@ -54,6 +54,11 @@ describe('keyText —— 主键拼法必须稳定', () => {
});
});
/** 本单碰过的行 —— `ledger.taskDrafts()` 那一份的形状 */
function touch(...ids: string[]) {
return ids.map((content_id) => ({ domain: 'content', object: 'asset', key: { content_id } }));
}
describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
it('一行一份,存下来的是真字节(存几份就写几笔)', async () => {
const db = fakeDb();
@@ -62,6 +67,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
taskId: TASK,
ids,
declarations: [ASSET],
touched: touch('CT-1', 'CT-2'),
rowsOfAddress: async () => [
{ content_id: 'CT-1', title: '白皮书', body_md: '# 白皮书\n\n正文', file_format: 'docx' },
{ content_id: 'CT-2', title: '对照表', body_md: '| a | b |\n| - | - |\n| 1 | 2 |', file_format: 'xlsx' },
@@ -72,6 +78,56 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
expect(db.writes.join()).toContain('白皮书');
});
/*
* 2026-08-25 柴浩实测:「工作成果怎么有 4 个资料,这单只会产出一个」——
* 资料落在**主数据**上,整表里躺着历次跑出来的资产,前三份都是别单的成果。
*/
it('**别单放行过的行不算这一单产出的** —— 整表四行,本单只碰过一行,就只存一份', async () => {
const db = fakeDb();
const saved = await archiveAssetFiles({
db,
taskId: TASK,
ids,
declarations: [ASSET],
touched: touch('CT-2026-0004'),
rowsOfAddress: async () => [
{ content_id: 'CT-2026-0001', title: '白皮书', body_md: '别单的正文', file_format: 'docx' },
{ content_id: 'CT-2026-0002', title: '白皮书 2026', body_md: '别单的正文', file_format: 'docx' },
{ content_id: 'CT-2026-0003', title: '产品介绍书', body_md: '别单的正文', file_format: 'docx' },
{ content_id: 'CT-2026-0004', title: '白皮书 E04 定制版', body_md: '这一单写的正文', file_format: 'docx' },
],
});
expect(saved).toBe(1);
expect(db.writes.join()).toContain('CT-2026-0004');
expect(db.writes.join()).not.toContain('CT-2026-0001');
});
it('这一单一行也没碰过:表里再多行也存 0 份', async () => {
const db = fakeDb();
const saved = await archiveAssetFiles({
db,
taskId: TASK,
ids,
declarations: [ASSET],
touched: [],
rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'md' }],
});
expect(saved).toBe(0);
});
it('别的表碰过不算这张表碰过 —— 地址得对上', async () => {
const db = fakeDb();
const saved = await archiveAssetFiles({
db,
taskId: TASK,
ids,
declarations: [ASSET],
touched: [{ domain: 'content_brand', object: 'requirement', key: { content_id: 'CT-1' } }],
rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'md' }],
});
expect(saved).toBe(0);
});
it('**没正文的行不存** —— 一份空壳存进去,人点开只会看见一张白纸', async () => {
const db = fakeDb();
const saved = await archiveAssetFiles({
@@ -79,6 +135,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
taskId: TASK,
ids,
declarations: [ASSET],
touched: touch('CT-1'),
rowsOfAddress: async () => [{ content_id: 'CT-1', title: '还没写', body_md: ' ', file_format: 'md' }],
});
expect(saved).toBe(0);
@@ -92,6 +149,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
taskId: TASK,
ids,
declarations: [ASSET],
touched: touch(''),
rowsOfAddress: async () => [{ content_id: '', title: 'T', body_md: '正文', file_format: 'md' }],
});
expect(saved).toBe(0);
@@ -104,6 +162,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
taskId: TASK,
ids,
declarations: [{ ...ASSET, file: undefined }],
touched: touch('CT-1'),
rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'md' }],
});
expect(saved).toBe(0);
@@ -116,6 +175,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
taskId: TASK,
ids,
declarations: [ASSET],
touched: touch('CT-1'),
rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'pdf' }],
});
expect(saved).toBe(1);
@@ -125,7 +185,32 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
it('大多数活动不产资料 —— 一张带 file 段的表都没有时返回 0,不报错', async () => {
const db = fakeDb();
expect(
await archiveAssetFiles({ db, taskId: TASK, ids, declarations: [], rowsOfAddress: async () => [] }),
await archiveAssetFiles({ db, taskId: TASK, ids, declarations: [], touched: [], rowsOfAddress: async () => [] }),
).toBe(0);
});
});
describe('assetRowsOf —— 存档与工作成果同一段', () => {
it('值取合并行那一份:这轮只改了标题,正文仍按落库后的样子造', () => {
const picked = assetRowsOf(
ASSET,
[{ content_id: 'CT-1', title: '改过的标题', body_md: '早先写下的正文', file_format: 'docx' }],
touch('CT-1'),
);
expect(picked).toEqual([
{
address: 'content.asset',
key: { content_id: 'CT-1' },
title: '改过的标题',
format: 'docx',
markdown: '早先写下的正文',
},
]);
});
it('标题空着:退主键,不摆一份没名字的资料', () => {
const picked = assetRowsOf(ASSET, [{ content_id: 'CT-1', title: ' ', body_md: '正文' }], touch('CT-1'));
expect(picked[0]?.title).toBe('CT-1');
expect(picked[0]?.format).toBe('md');
});
});
@@ -0,0 +1,164 @@
/** 小 Hono + 假的 org 出口:中间件的四个出口(放行 / 401 / 403 / 票据)逐一走一遍 */
import { describe, expect, it } from 'vitest';
import { Hono } from 'hono';
import type { Position, Principal, Surface } from '@contracts';
import { positionIdSchema, staffIdSchema } from '@contracts';
import { AccountDisabledError, TokenRejectedError } from '@modules/org';
import { bearerOf, createAuthMiddleware, forbiddenBody, requiredEnds } from '../auth-middleware.ts';
import type { AppEnv } from '../http-env.ts';
import { buildRouteRegistry } from '../route-registry.ts';
import { createTicketIssuer } from '../ticket.ts';
const position: Position = { id: positionIdSchema.parse('none'), code: 'NONE', name: '未分配' };
const principal = (ends: Surface[], roles: string[]): Principal => ({
staff: { id: staffIdSchema.parse('sub-1'), account: 'alice', name: '爱丽丝', positionId: position.id, state: 'active', roles },
position,
roles,
ends,
});
const ADMIN = principal(['design', 'ops', 'workbench'], ['admin']);
const USER = principal(['workbench'], ['user']);
const NOBODY = principal([], ['auditor']);
const tickets = createTicketIssuer(Buffer.alloc(32, 3));
const now = new Date('2026-08-25T09:00:00Z');
function makeApp(): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.use(
'*',
createAuthMiddleware({
registry: buildRouteRegistry([
{ method: 'GET', path: '/api/health', access: 'public' },
{ method: 'GET', path: '/api/me', access: 'any' },
{ method: 'GET', path: '/api/desk/tasks', access: 'workbench' },
{ method: 'GET', path: '/api/gov/tasks', access: 'ops' },
{ method: 'POST', path: '/api/gov/tasks/:taskId/control', access: 'ops' },
{ method: 'GET', path: '/api/gov/staff', access: ['design', 'ops'] },
]),
org: {
authenticate: async (token) => {
if (token === 'admin') return ADMIN;
if (token === 'user') return USER;
if (token === 'nobody') return NOBODY;
if (token === 'disabled') throw new AccountDisabledError('alice');
throw new TokenRejectedError('签名校验失败 —— token 被篡改,或来自另一个 Keycloak 实例');
},
principalOf: async (id) => (id === 'sub-1' ? USER : undefined),
},
tickets,
clock: { now: () => now },
}),
);
app.get('/api/health', (c) => c.json({ ok: true }));
app.get('/api/me', (c) => c.json({ account: c.get('principal')?.staff.account ?? null }));
app.get('/api/desk/tasks', (c) => c.json({ ok: true }));
app.get('/api/gov/tasks', (c) => c.json({ ok: true }));
app.post('/api/gov/tasks/:taskId/control', (c) => c.json({ ok: true }));
app.get('/api/gov/staff', (c) => c.json({ ok: true }));
return app;
}
const app = makeApp();
// app.request() 的类型签名是 Response | Promise<Response>(Hono 4.12);包一层 Promise.resolve
// 只是把类型收窄成调用方好用的 Promise<Response>,不改变行为(await 对两者结果一致)。
const get = (path: string, headers: Record<string, string> = {}): Promise<Response> =>
Promise.resolve(app.request(path, { headers }));
const post = (path: string, headers: Record<string, string> = {}): Promise<Response> =>
Promise.resolve(app.request(path, { method: 'POST', headers }));
const head = (path: string, headers: Record<string, string> = {}): Promise<Response> =>
Promise.resolve(app.request(path, { method: 'HEAD', headers }));
type Body = { code: string; message: string; detail?: { need: string[]; ends: string[] } };
describe('鉴权中间件', () => {
it('public 不要凭据', async () => {
expect((await get('/api/health')).status).toBe(200);
});
it('没凭据 → 401', async () => {
const res = await get('/api/me');
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ code: 'unauthenticated', message: '缺少 Bearer token' });
});
it('坏 token → 401 带人话', async () => {
const res = await get('/api/me', { authorization: 'Bearer garbage' });
expect(res.status).toBe(401);
expect(((await res.json()) as Body).message).toContain('签名校验失败');
});
it('any:登录即可,连无端可进的人也行', async () => {
const res = await get('/api/me', { authorization: 'Bearer nobody' });
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ account: 'alice' });
});
it('端不够 → 403,说清需要哪个端、你能进哪几个', async () => {
const res = await get('/api/gov/tasks', { authorization: 'Bearer user' });
expect(res.status).toBe(403);
const body = (await res.json()) as Body;
expect(body.code).toBe('forbidden');
expect(body.message).toContain('运营中心');
expect(body.message).toContain('工作台');
expect(body.detail).toEqual({ need: ['ops'], ends: ['workbench'] });
});
it('端够了 → 放行;数组归属任一命中即可', async () => {
expect((await get('/api/gov/tasks', { authorization: 'Bearer admin' })).status).toBe(200);
expect((await get('/api/desk/tasks', { authorization: 'Bearer user' })).status).toBe(200);
expect((await get('/api/gov/staff', { authorization: 'Bearer admin' })).status).toBe(200);
expect((await get('/api/gov/staff', { authorization: 'Bearer user' })).status).toBe(403);
});
it('账号停用 → 403(重新登录没用)', async () => {
const res = await get('/api/me', { authorization: 'Bearer disabled' });
expect(res.status).toBe(403);
expect(((await res.json()) as Body).message).toContain('停用');
});
it('票据:GET 上认,POST 上不认', async () => {
const { ticket } = tickets.issue('sub-1', now);
expect((await get(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`)).status).toBe(200);
expect((await post(`/api/gov/tasks/t-1/control?ticket=${encodeURIComponent(ticket)}`)).status).toBe(401);
});
it('过期票据 → 401 指路重取', async () => {
const { ticket } = tickets.issue('sub-1', new Date(now.getTime() - 120_000));
const res = await get(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`);
expect(res.status).toBe(401);
expect(((await res.json()) as Body).message).toContain('/api/auth/ticket');
});
it('Bearer 与票据同时在:Bearer 说了算', async () => {
const { ticket } = tickets.issue('sub-1', now);
expect((await get(`/api/gov/tasks?ticket=${encodeURIComponent(ticket)}`, { authorization: 'Bearer admin' })).status).toBe(200);
});
it('HEAD 按 GET 判:端不够 → 403,与 GET 同款', async () => {
// Hono 在 #dispatch 里把 HEAD 改写成 GET 再分发,而 c.req.method 仍是 'HEAD' ——
// 不归一的话查表必然 miss、落进「any」兜底,于是「只要登录就能跑任何 GET 处理器」。
expect((await get('/api/gov/tasks', { authorization: 'Bearer user' })).status).toBe(403);
expect((await head('/api/gov/tasks', { authorization: 'Bearer user' })).status).toBe(403);
});
it('HEAD 该进的还得进:端够了 → 200', async () => {
expect((await head('/api/desk/tasks', { authorization: 'Bearer user' })).status).toBe(200);
});
it('HEAD 上的票据与 GET 同款(归一之后两条路一致)', async () => {
const { ticket } = tickets.issue('sub-1', now);
expect((await head(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`)).status).toBe(200);
expect((await head('/api/gov/tasks', {})).status).toBe(401);
});
it('未知路由:没凭据先 401,有凭据再 404', async () => {
expect((await get('/api/nope')).status).toBe(401);
expect((await get('/api/nope', { authorization: 'Bearer user' })).status).toBe(404);
});
});
describe('小件', () => {
it('bearerOf 认大小写与空白', () => {
expect(bearerOf('bearer abc')).toBe('abc');
expect(bearerOf('Bearer abc ')).toBe('abc');
expect(bearerOf('Basic abc')).toBeUndefined();
expect(bearerOf(undefined)).toBeUndefined();
expect(bearerOf('Bearer ')).toBeUndefined();
});
it('requiredEnds', () => {
expect(requiredEnds('public')).toBeUndefined();
expect(requiredEnds('any')).toBeUndefined();
expect(requiredEnds('ops')).toEqual(['ops']);
expect(requiredEnds(['design', 'ops'])).toEqual(['design', 'ops']);
});
it('forbiddenBody 对无端可进的人如实说', () => {
expect(forbiddenBody(['design'], NOBODY).message).toContain('无端可进');
});
});
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { loadConfig } from '../config.ts';
describe('Keycloak 配置(ADR-012)', () => {
it('三样齐了才有 keycloak 段;尾斜杠去掉;issuer / jwks 拼好', () => {
const c = loadConfig({ KEYCLOAK_URL: 'https://kc.test/', KEYCLOAK_REALM: 'corp', KEYCLOAK_CLIENT_ID: 'api-gateway' });
expect(c.keycloak).toEqual({
url: 'https://kc.test',
realm: 'corp',
clientId: 'api-gateway',
issuer: 'https://kc.test/realms/corp',
jwksUrl: 'https://kc.test/realms/corp/protocol/openid-connect/certs',
});
});
it('缺一样就没有 keycloak 段(main.ts 据此拒绝启动)', () => {
expect(loadConfig({ KEYCLOAK_URL: 'https://kc.test', KEYCLOAK_REALM: 'corp' }).keycloak).toBeUndefined();
expect(loadConfig({ KEYCLOAK_URL: ' ', KEYCLOAK_REALM: 'corp', KEYCLOAK_CLIENT_ID: 'x' }).keycloak).toBeUndefined();
});
it('provisioning 两样都给才带', () => {
const base = { KEYCLOAK_URL: 'https://kc.test', KEYCLOAK_REALM: 'corp', KEYCLOAK_CLIENT_ID: 'x' };
expect(
loadConfig({ ...base, KEYCLOAK_PROVISIONING_CLIENT_ID: 'provisioning', KEYCLOAK_PROVISIONING_CLIENT_SECRET: 's3' }).keycloak
?.provisioning,
).toEqual({ clientId: 'provisioning', secret: 's3' });
expect(loadConfig({ ...base, KEYCLOAK_PROVISIONING_CLIENT_ID: 'provisioning' }).keycloak?.provisioning).toBeUndefined();
});
it('测试账号逗号分隔、去空白、丢空项', () => {
expect(loadConfig({ JCAI_TEST_ACCOUNTS: ' ai, chaihao ,, ' }).testAccounts).toEqual(['ai', 'chaihao']);
expect(loadConfig({}).testAccounts).toEqual([]);
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import type { KeycloakConfig } from '../config.ts';
import { checkKeycloakConnection } from '../keycloak-check.ts';
const kc: KeycloakConfig = {
url: 'https://kc.test',
realm: 'corp',
clientId: 'api-gateway',
issuer: 'https://kc.test/realms/corp',
jwksUrl: 'https://kc.test/realms/corp/protocol/openid-connect/certs',
};
const reply = (status: number, body: unknown): typeof fetch => async () =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
describe('启动自检(手册第 5 步)', () => {
it('issuer 一致 → 回 issuer 与 clientId', async () => {
await expect(checkKeycloakConnection(kc, reply(200, { issuer: kc.issuer }))).resolves.toEqual({
issuer: kc.issuer,
clientId: 'api-gateway',
});
});
it('issuer 不一致 → 抛,话里带期望 / 实际 / KC_HOSTNAME', async () => {
await expect(checkKeycloakConnection(kc, reply(200, { issuer: 'http://localhost:8080/realms/corp' }))).rejects.toThrow(
/KC_HOSTNAME/,
);
});
it('发现文档拉不到 → 抛带状态码', async () => {
await expect(checkKeycloakConnection(kc, reply(503, {}))).rejects.toThrow(/503/);
});
});
@@ -0,0 +1,47 @@
/** 轮询工作者:每 tick 调 pollDue;上一 tick 没跑完就跳过这一拍;出错的账号写 stderr;stop 后不再跑 */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MailPollOutcome } from '@contracts';
import { pollMailOnce, startMailPoller } from '../workers/mail-poller.ts';
function fakeMail(outcomes: MailPollOutcome[], holdMs = 0) {
const calls: Date[] = [];
return {
calls,
mail: {
pollDue: async (now: Date) => {
calls.push(now);
if (holdMs > 0) await new Promise((r) => setTimeout(r, holdMs));
return outcomes;
},
},
};
}
describe('pollMailOnce', () => {
it('回用例的结果;出错的账号写一行 stderr', async () => {
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const { mail } = fakeMail([{ accountId: 'a', pulled: 1, stored: 1, dispatched: 1, more: false }, { accountId: 'b', pulled: 0, stored: 0, dispatched: 0, more: false, error: '连不上' }]);
const out = await pollMailOnce({ container: { mail: mail as never } });
expect(out).toHaveLength(2);
expect(write).toHaveBeenCalledWith(expect.stringContaining('[mail-poller] b:连不上'));
write.mockRestore();
});
});
describe('startMailPoller', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('按 tick 调;在跑时跳过;stop 后停', async () => {
const { mail, calls } = fakeMail([], 1000);
const poller = startMailPoller({ container: { mail: mail as never } }, 100);
await vi.advanceTimersByTimeAsync(350);
expect(calls).toHaveLength(1); // 第一拍 1000ms 没跑完,后面两拍跳过
await vi.advanceTimersByTimeAsync(1000);
expect(calls.length).toBeGreaterThanOrEqual(2);
poller.stop();
const before = calls.length;
await vi.advanceTimersByTimeAsync(2000);
expect(calls).toHaveLength(before);
});
});
@@ -0,0 +1,32 @@
/**
* 邮件路由的归属:15 条全部进契约,注册表建得起来(字面 / 参数模版互不撞),
* 读写都归运营中心(设计稿 §7.2 + 2026-08-26 裁决:设计端没有消费方,不再无故加宽)。
*/
import { describe, expect, it } from 'vitest';
import * as contracts from '@contracts';
import { MAIL_ROUTES } from '@contracts';
import { buildRouteRegistry, routesOf } from '../route-registry.ts';
describe('邮件路由契约', () => {
it('15 条全在契约命名空间里,注册表能建起来', () => {
const all = routesOf(Object.values(contracts));
const registry = buildRouteRegistry(all);
expect(MAIL_ROUTES).toHaveLength(15);
for (const route of MAIL_ROUTES) {
expect(registry.has(route.method, route.path)).toBe(true);
}
});
it('读写都归 ops', () => {
for (const route of MAIL_ROUTES) {
expect(route.access).toBe('ops');
}
});
it('未存草稿探测与已存账号探测是两条不同的路', () => {
const registry = buildRouteRegistry(routesOf(Object.values(contracts)));
expect(registry.lookup('POST', '/api/gov/mail/accounts/probe')).toBe('ops');
expect(registry.lookup('POST', '/api/gov/mail/accounts/acc-1/probe')).toBe('ops');
expect(registry.lookup('GET', '/api/gov/mail/inbound/m-1/attachments/0')).toBe('ops');
});
});
@@ -0,0 +1,119 @@
/**
* 邮件路由:zod 校验 → 用例 → 领域错误翻状态码;厂商失败在 200 的 body 里如实说。
* 不挂鉴权中间件(归属由路由覆盖测试与注册表测试兜);用例是真的,store 是内存的,provider 是假的。
*/
import { Hono } from 'hono';
import { describe, expect, it } from 'vitest';
import { createMailService, createPostalMimeParser } from '@modules/mail';
import type { AppEnv } from '../http-env.ts';
import { registerMailRoutes } from '../routes/mail.ts';
import { account, eml, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, rawMessage, seqIds } from '../../modules/mail/__tests__/fakes.ts';
function harness() {
const clock = fixedClock();
const accounts = memoryAccounts(clock);
const imap = fakeProvider('imap-smtp');
const mail = createMailService({
accounts,
inbound: memoryInbound(),
outbound: memoryOutbound(),
attachments: memoryAttachments(),
providers: { 'imap-smtp': imap, wecom: fakeProvider('wecom') },
mime: createPostalMimeParser(),
secrets: plainSecrets,
clock,
ids: seqIds('x'),
notify: { changed: () => undefined },
});
const app = new Hono<AppEnv>();
registerMailRoutes(app, { mail });
const json = (method: string, path: string, body?: unknown) =>
app.request(path, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
return { app, mail, accounts, imap, json };
}
const input = { kind: 'imap-smtp', name: '销售', address: 'sales@we.com', isDefault: true, secret: { password: 'pw-1234' } };
describe('账号路由', () => {
it('列表 / 建 / 取 / 改 / 删;400 / 404 / 409', async () => {
const h = harness();
expect(await (await h.json('GET', '/api/gov/mail/accounts')).json()).toEqual({ accounts: [] });
expect((await h.json('POST', '/api/gov/mail/accounts', { kind: 'imap-smtp' })).status).toBe(400);
const created = await h.json('POST', '/api/gov/mail/accounts', input);
expect(created.status).toBe(200);
const view = (await created.json()) as { id: string; secretBound: boolean };
expect(view.secretBound).toBe(true);
expect(JSON.stringify(view)).not.toContain('pw-1234');
expect((await h.json('POST', '/api/gov/mail/accounts', input)).status).toBe(409);
expect((await h.json('GET', `/api/gov/mail/accounts/${view.id}`)).status).toBe(200);
expect((await h.json('GET', '/api/gov/mail/accounts/nope')).status).toBe(404);
expect((await h.json('PUT', `/api/gov/mail/accounts/${view.id}`, { ...input, displayName: '销售部' })).status).toBe(200);
expect(await (await h.json('DELETE', `/api/gov/mail/accounts/${view.id}`)).json()).toEqual({ ok: true });
expect((await h.json('DELETE', `/api/gov/mail/accounts/${view.id}`)).status).toBe(404);
});
it('探测:草稿没密码 412;已存账号 200 且厂商失败在 body 里', async () => {
const h = harness();
expect((await h.json('POST', '/api/gov/mail/accounts/probe', { ...input, secret: undefined })).status).toBe(412);
const view = (await (await h.json('POST', '/api/gov/mail/accounts', input)).json()) as { id: string };
expect((await h.json('POST', '/api/gov/mail/accounts/probe?accountId=' + view.id, { ...input, secret: undefined })).status).toBe(200);
h.imap.probeWith({ kind: 'imap-smtp', receive: { ok: false, error: '535 pw-1234 bad' }, send: { checked: true, ok: true } });
const res = await h.json('POST', `/api/gov/mail/accounts/${view.id}/probe`);
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ receive: { ok: false, error: '535 *** bad' } });
});
it('立即拉取:200 outcome;停用 412;租约被占 409', async () => {
const h = harness();
await h.accounts.save(account());
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>' })));
expect(await (await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).json()).toMatchObject({ pulled: 1, stored: 1 });
await h.accounts.save(account({ enabled: false }));
expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).status).toBe(412);
// 租约是**运行列**:`save()` 只写配置列(整行 upsert 会盖掉另一台正持着的租约),
// 所以按真路径占它 —— claimPolling 就是另一台机器那一下
await h.accounts.save(account());
expect(await h.accounts.claimPolling('acc-1', 60_000)).toBe(true);
expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).status).toBe(409);
});
it('试发:成功与厂商失败都是 200,state 说话;入参错 400', async () => {
const h = harness();
await h.accounts.save(account());
const ok = await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'me@we.com', subject: '试发', text: '正文' });
expect(await ok.json()).toMatchObject({ state: 'sent', messageId: '<x-1@we.com>' });
h.imap.failSendWith(new Error('535 nope'));
expect(await (await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'me@we.com', subject: '试发', text: '正文' })).json()).toMatchObject({ state: 'failed', error: '535 nope' });
expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'not-mail', subject: '', text: '' })).status).toBe(400);
});
});
describe('入站 / 出站路由', () => {
it('列表(limit)/ 详情 / 附件 / 原件 / 重新投递;404 各就各位', async () => {
const h = harness();
await h.accounts.save(account());
h.mail.subscribe('spy', async () => undefined);
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'hi' } })));
await h.json('POST', '/api/gov/mail/accounts/acc-1/poll');
const list = (await (await h.json('GET', '/api/gov/mail/inbound?accountId=acc-1&limit=10')).json()) as { mails: Array<{ id: string; deliveries: unknown[] }> };
expect(list.mails).toHaveLength(1);
expect(list.mails[0]?.deliveries).toHaveLength(1);
const id = list.mails[0]!.id;
expect((await h.json('GET', `/api/gov/mail/inbound/${id}`)).status).toBe(200);
expect((await h.json('GET', '/api/gov/mail/inbound/nope')).status).toBe(404);
const att = (await (await h.json('GET', `/api/gov/mail/inbound/${id}/attachments/0`)).json()) as { name: string; dataBase64: string };
expect(att.name).toBe('a.txt');
expect(Buffer.from(att.dataBase64, 'base64').toString()).toBe('hi');
expect((await h.json('GET', `/api/gov/mail/inbound/${id}/attachments/7`)).status).toBe(404);
const raw = (await (await h.json('GET', `/api/gov/mail/inbound/${id}/raw`)).json()) as { contentType: string };
expect(raw.contentType).toBe('message/rfc822');
expect(await (await app_request_redeliver(h, id)).json()).toMatchObject({ deliveries: [{ subscriber: 'spy', state: 'done' }] });
expect((await h.json('POST', `/api/gov/mail/inbound/${id}/redeliver`, { subscriber: 'nobody' })).status).toBe(404);
expect((await h.json('GET', '/api/gov/mail/inbound?limit=0')).status).toBe(400);
expect(await (await h.json('GET', '/api/gov/mail/outbound')).json()).toEqual({ mails: [] });
});
});
/** 空 body 的 POST:redeliver 的 body 是可省的 */
function app_request_redeliver(h: ReturnType<typeof harness>, id: string): Promise<Response> {
return Promise.resolve(h.app.request(`/api/gov/mail/inbound/${id}/redeliver`, { method: 'POST' }));
}
@@ -0,0 +1,172 @@
/** mail ↔ channel / toolbox 的接线:三段纯映射各自成立;dryRun 不发;失败翻成工具错误 */
import { describe, expect, it } from 'vitest';
import type { MailInboundDetail, MailSendInput, MailSendResult } from '@contracts';
import type { InboundMail } from '@contracts';
import { auditPassed, checkEvolution, staticAudit } from '@modules/toolbox';
import type { ToolContract } from '@modules/toolbox';
import { threadIdOf } from '@modules/mail';
import { channelSubscriber, channelTransport, mailSendTool, toInboundMail } from '../mail-wiring.ts';
import { MAIL_TOOL } from '../seed-mvp.ts';
const detail: MailInboundDetail = {
id: 'in-1',
accountId: 'acc-1',
messageId: '<m1@acme.com>',
from: { address: 'buyer@acme.com', name: '张三' },
to: [{ address: 'sales@we.com', name: '' }],
cc: [{ address: 'ops@we.com', name: 'Ops' }],
subject: '询价 RFQ-1',
sentAt: '2026-08-25T09:00:00.000Z',
receivedAt: '2026-08-25T09:30:00.000Z',
textPreview: '你好',
hasHtml: true,
attachments: [
{ index: 0, name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1', note: '' },
{ index: 1, name: 'big.zip', contentType: 'application/zip', size: 9_000_000, ref: null, note: '超限' },
],
deliveries: [],
inReplyTo: null,
references: ['<root@we.com>', '<q1@we.com>'],
text: null,
html: '<p>你好</p>',
size: 100,
rawAvailable: true,
};
function fakeSend(result: Partial<MailSendResult> = {}): { send: (input: MailSendInput) => Promise<MailSendResult>; calls: MailSendInput[] } {
const calls: MailSendInput[] = [];
return {
calls,
send: async (input) => {
calls.push(input);
return { outboundId: 'out-1', accountId: 'acc-1', messageId: '<out-1@we.com>', state: 'sent', ...result };
},
};
}
describe('toInboundMail', () => {
it('线程取 References 首项(即这一串的根);只带存下来的附件;纯 html 剥成正文', () => {
const mail: InboundMail = toInboundMail(detail);
// 不是末项 <q1@we.com>:我方出站登记的恒是首封,①级归属是精确等值比对 ——
// 取最近一跳的话,跑一个来回(我方再回一封)线程就漂到库里没记过的 Message-ID 上,①级落空退③级
expect(mail.threadId).toBe('<root@we.com>');
expect(mail.from).toBe('buyer@acme.com');
expect(mail.to).toEqual(['sales@we.com']);
expect(mail.cc).toEqual(['ops@we.com']);
expect(mail.body).toBe('你好');
expect(mail.attachments).toEqual([{ name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1' }]);
expect(mail.receivedAt).toBe('2026-08-25T09:30:00.000Z');
});
it('线程口径只有一处:这里就是 mail 模块的 threadIdOf,不是另抄的一份', () => {
// 从前这里内联了一份同义实现,而模块导出的 threadIdOf 取的是**最近一跳** ——
// 同一个模块两套相反的语义,下一个人照导出的那个接线就会接反
expect(toInboundMail(detail).threadId).toBe(threadIdOf(detail));
const onlyHop: MailInboundDetail = { ...detail, references: [], inReplyTo: '<q1@we.com>' };
expect(toInboundMail(onlyHop).threadId).toBe(threadIdOf(onlyHop));
expect(toInboundMail(onlyHop).threadId).toBe('<q1@we.com>');
const none: MailInboundDetail = { ...detail, references: [], inReplyTo: null };
expect(toInboundMail(none).threadId).toBeUndefined();
});
});
describe('channelSubscriber', () => {
it('账号没绑通道 → skipped;绑了就 ingestMail(通道 id + 归一后的信)', async () => {
const calls: Array<[string, InboundMail]> = [];
const handler = channelSubscriber({
ingestMail: async (id, mail) => {
calls.push([id, mail]);
return undefined;
},
});
expect(await handler(detail, { accountId: 'acc-1', channelId: undefined })).toEqual({ skipped: '账号未绑定通道' });
expect(await handler(detail, { accountId: 'acc-1', channelId: 'ch-1' })).toBeUndefined();
expect(calls[0]?.[0]).toBe('ch-1');
expect(calls[0]?.[1].messageId).toBe('<m1@acme.com>');
});
});
describe('channelTransport', () => {
it('按 from 找账号、threadId 进 inReplyTo / references、附件 ref 变 uploadRef;回 messageId 与线程', async () => {
const mail = fakeSend();
const sent = await channelTransport(mail).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: '报价', body: '正文', threadId: '<m1@acme.com>', attachments: [{ name: 'q.pdf', ref: 'up-9' }] });
expect(sent).toEqual({ messageId: '<out-1@we.com>', threadId: '<m1@acme.com>' });
expect(mail.calls[0]).toMatchObject({ from: 'sales@we.com', inReplyTo: '<m1@acme.com>', references: ['<m1@acme.com>'], text: '正文', attachments: [{ name: 'q.pdf', uploadRef: 'up-9' }], requestedBy: { kind: 'channel' } });
const fresh = await channelTransport(fakeSend()).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: 's', body: 'b', attachments: [] });
expect(fresh.threadId).toBe('<out-1@we.com>');
});
it('厂商失败抛出,让 channel.sendMail 的调用方看到', async () => {
await expect(channelTransport(fakeSend({ state: 'failed', error: '535 auth failed' })).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: 's', body: 'b', attachments: [] })).rejects.toThrow('535 auth failed');
});
});
describe('mailSendTool', () => {
const base = { connection: { account: 'default' }, operation: undefined, idempotencyKey: 'k-1' };
it('数组 / 逗号串都认作收件人;body → text;幂等键与连接配置的账号带过去;出参保留 queued', async () => {
const mail = fakeSend();
const result = await mailSendTool(mail)({ ...base, connection: { account: 'acc-2' }, input: { to: 'a@x.com; b@x.com', subject: 's', body: '正文', inReplyTo: '<m1@a>', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }] } });
expect(result).toEqual({ ok: true, output: { queued: true, outboundId: 'out-1', messageId: '<out-1@we.com>', state: 'sent' } });
expect(mail.calls[0]).toMatchObject({ accountId: 'acc-2', to: ['a@x.com', 'b@x.com'], text: '正文', inReplyTo: '<m1@a>', references: ['<m1@a>'], idempotencyKey: 'k-1', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }], requestedBy: { kind: 'tool', tool: 'mail.send' } });
await mailSendTool(mail)({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } });
expect(mail.calls[1]?.accountId).toBeUndefined();
});
it('没给 uploadRef 的附件不静默丢掉:整次调用判 invalid,一封都不发', async () => {
// 模块那一层本就是硬的(既没字节也没 ref 就抛 MailSendInvalidError);app 层不许比它松 ——
// 悄悄丢掉附件再回 queued:true,等于让界面撒谎
const mail = fakeSend();
const result = await mailSendTool(mail)({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }, { name: 'no-ref' }] } });
expect(result.ok).toBe(false);
expect(result.error?.code).toBe('invalid');
expect(result.error?.retryable).toBe(false);
expect(result.error?.message).toContain('no-ref');
expect(mail.calls).toHaveLength(0);
});
it('dryRun 只校验不发', async () => {
const mail = fakeSend();
expect(await mailSendTool(mail)({ ...base, input: { to: ['x@y.com'], subject: '询价', body: 'b', dryRun: true } })).toEqual({ ok: true, output: { queued: false, dryRun: true } });
expect((await mailSendTool(mail)({ ...base, input: { to: [], subject: '', dryRun: true } })).ok).toBe(false);
expect(mail.calls).toHaveLength(0);
});
it('厂商失败 → send-failed 可重试;没账号 → no-account 不重试;入参问题 → invalid', async () => {
const failed = await mailSendTool(fakeSend({ state: 'failed', error: '535' }))({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } });
expect(failed).toEqual({ ok: false, error: { code: 'send-failed', retryable: true, message: '535' } });
const { MailNoDefaultAccountError, MailSendInvalidError } = await import('@modules/mail');
const noAccount = await mailSendTool({ send: async () => { throw new MailNoDefaultAccountError(); } })({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } });
expect(noAccount.error?.code).toBe('no-account');
const invalid = await mailSendTool({ send: async () => { throw new MailSendInvalidError(['正文 text / html 至少一个']); } })({ ...base, input: { to: ['a@x.com'], subject: 's' } });
expect(invalid.error?.code).toBe('invalid');
});
});
describe('mail.send 2.0.0 的演进', () => {
/*
* **对真契约断言,不对手抄的夹具**。上一版这里的 v1/v2 都是照着 `seed-mvp.ts` 手抄的两份 JSON:
* 改真的那一份它不会红(自证),而且抄的 v1 版本号还写成了 `1.0.0` ——
* 真前一版是 `1.1.0`(MAIL_TOOL 展开 HISTORY_TOOL,版本跟着它)。
* v1 是从 git 里那一版逐字取回来的**历史快照**(只有它必须手写:那份代码已经不在仓里了)。
*/
const v1: ToolContract = {
...MAIL_TOOL,
version: '1.1.0',
description: '发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界',
input: { type: 'object', required: ['to', 'subject'], properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } } },
output: { type: 'object', properties: { queued: { type: 'boolean' } } },
connectionKeys: ['endpoint'],
};
it('真契约就是 2.0.0,且过得了静态审', () => {
expect(MAIL_TOOL.name).toBe('mail.send');
expect(MAIL_TOOL.version).toBe('2.0.0');
expect(auditPassed(staticAudit(MAIL_TOOL))).toBe(true);
});
it('保留 v1 的 body / queued、不新增必填 —— 演进检查零违规', () => {
expect(checkEvolution(v1, MAIL_TOOL)).toEqual([]);
// 反证这条断言真的在看东西:真把 v1 的 body 删掉就该报违规
const dropped: ToolContract = {
...MAIL_TOOL,
input: { type: 'object', required: ['to', 'subject'], properties: { to: { type: 'array' }, subject: { type: 'string' } } },
};
expect(checkEvolution(v1, dropped).map((f) => f.field)).toContain('input.body');
});
});
@@ -80,12 +80,14 @@ beforeAll(async () => {
await pool.query(`create schema "${schema}"`);
}
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url }));
container = await createContainer(
loadConfig({ ...process.env, DATABASE_URL: database.url, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }),
);
await container.org.ensureSeeded();
// 种子只为**装载活动**(六个槽位)—— 新流程包引用它们,不改它们一行
await seedMvp(container);
const { staff } = await container.org.listStaff();
chaihao = staff.find((s) => s.account === 'Chaihao')!.id;
// 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己)
chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id;
}, 180_000);
afterAll(async () => {
@@ -0,0 +1,96 @@
/**
* 路由覆盖(fail-closed,ADR-012):Hono 里每一条注册了的路由都得在路由注册表里有归属。
* 漏一条 = 一个没人管的口子,这里把它变成测试红。要真容器只是为了把路由都挂上,不发请求。
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadConfig } from '../config.ts';
import { createContainer } from '../container.ts';
import type { Container } from '../container.ts';
import { appRouteRegistry, createServer } from '../server.ts';
const IT_DB = 'jcai_it_routes';
async function provisionDatabase(): Promise<{ url: string; stop(): Promise<void> } | undefined> {
const explicit = process.env['JCAI_TEST_DATABASE_URL'];
if (explicit !== undefined) return { url: explicit, stop: async () => undefined };
const base = process.env['DATABASE_URL'] ?? 'postgres://jcai:jcai@localhost:54329/jcai';
try {
const admin = new Pool({ connectionString: base, max: 1, connectionTimeoutMillis: 1500 });
const exists = await admin.query(`select 1 from pg_database where datname = $1`, [IT_DB]);
if (exists.rowCount === 0) await admin.query(`create database ${IT_DB}`);
await admin.end();
return { url: base.replace(/\/[^/]*$/, `/${IT_DB}`), stop: async () => undefined };
} catch {
/* 开发实例连不上,试 Testcontainers */
}
try {
const { PostgreSqlContainer } = await import('@testcontainers/postgresql');
const started = await new PostgreSqlContainer('postgres:16-alpine').start();
return { url: started.getConnectionUri(), stop: () => started.stop().then(() => undefined) };
} catch (error) {
process.stderr.write(
`⚠️ 路由覆盖 it 跳过 —— **这道 fail-closed 的安全保险这一次没有跑**:` +
`「写了路由处理器却没有契约声明 access」这一类漏网,本次构建没有检查。\n` +
` 连不上开发实例也起不了 Testcontainers(${error instanceof Error ? error.message : String(error)})\n`,
);
return undefined;
}
}
function migrationsFolder(): string {
let dir = dirname(fileURLToPath(import.meta.url));
for (let i = 0; i < 8; i += 1) {
const candidate = join(dir, 'db', 'migrations');
if (existsSync(candidate)) return candidate;
const parent = resolve(dir, '..');
if (parent === dir) break;
dir = parent;
}
throw new Error('找不到 db/migrations');
}
const database = await provisionDatabase();
let pool: Pool | undefined;
let container: Container | undefined;
beforeAll(async () => {
if (database === undefined) return;
pool = new Pool({ connectionString: database.url, max: 4 });
for (const schema of ['sys', 'ledger', 'mastra', 'queue']) {
await pool.query(`drop schema if exists "${schema}" cascade`);
await pool.query(`create schema "${schema}"`);
}
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url }));
}, 180_000);
afterAll(async () => {
await container?.close();
await pool?.end();
await database?.stop();
});
describe.skipIf(database === undefined)('路由覆盖', () => {
it('Hono 里每条注册的路由都在归属注册表里', () => {
const app = createServer(container!);
const registry = appRouteRegistry();
const registered = app.routes.filter((r) => r.method !== 'ALL');
/*
* 先钉下界:`missing` 为空有两种可能 —— 真的一条不漏,或者**根本没数到路由**
* (createServer 改用子应用组合、Hono 换了 routes 的形状、建 server 中途早退)。
* 空集是任何集合的子集,不设下界的话后一种情况会伪装成前一种,
* 这道保险就从「会红」变成「永远绿」。
*/
expect(registered.length, '一条路由都没数到 —— 先查 createServer 与 app.routes 的形状').toBeGreaterThan(50);
const missing = registered.filter((r) => !registry.has(r.method, r.path)).map((r) => `${r.method} ${r.path}`);
expect(missing, '这些路由没有声明归哪个端(契约加 access,或 EXTRA_ROUTES 补入)').toEqual([]);
});
});
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest';
import * as contracts from '@contracts';
import { buildRouteRegistry, isRouteContract, routesOf } from '../route-registry.ts';
const registry = buildRouteRegistry([
{ method: 'GET', path: '/api/health', access: 'public' },
{ method: 'GET', path: '/api/me', access: 'any' },
{ method: 'GET', path: '/api/gov/tasks', access: 'ops' },
{ method: 'POST', path: '/api/gov/tasks/:taskId/control', access: 'ops' },
{ method: 'GET', path: '/api/gov/testbed/logs', access: 'design' },
{ method: 'GET', path: '/api/gov/testbed/logs/:id', access: 'design' },
{ method: 'POST', path: '/api/gov/process/validate', access: 'design' },
{ method: 'POST', path: '/api/gov/process/:id/:version/state', access: 'design' },
{ method: 'GET', path: '/api/gov/staff', access: ['design', 'ops'] },
]);
describe('路由注册表', () => {
it('字面路径精确命中', () => {
expect(registry.lookup('GET', '/api/gov/tasks')).toBe('ops');
expect(registry.lookup('GET', '/api/health')).toBe('public');
});
it('参数段:一段认一段,多一段少一段都不算', () => {
expect(registry.lookup('POST', '/api/gov/tasks/t-1/control')).toBe('ops');
expect(registry.lookup('POST', '/api/gov/tasks/t-1/x/control')).toBeUndefined();
expect(registry.lookup('POST', '/api/gov/process/p1/3/state')).toBe('design');
});
it('方法不同不混', () => {
expect(registry.lookup('POST', '/api/gov/tasks')).toBeUndefined();
expect(registry.lookup('GET', '/api/gov/tasks/t-1/control')).toBeUndefined();
});
it('字面优先于参数段(logs 与 logs/:id 各归各)', () => {
expect(registry.lookup('GET', '/api/gov/testbed/logs')).toBe('design');
expect(registry.lookup('GET', '/api/gov/testbed/logs/abc')).toBe('design');
expect(registry.lookup('GET', '/api/gov/testbed/logs/abc/def')).toBeUndefined();
});
it('未知路由 → undefined', () => {
expect(registry.lookup('GET', '/api/nope')).toBeUndefined();
});
it('只认五个 HTTP 方法:HEAD 查不到 —— 归一是中间件的职责,不是表的', () => {
// 框架把 HEAD 改写成 GET 再分发,而请求行上的方法仍是 HEAD;
// 表只按 HTTP_METHODS 装,所以这里如实回 undefined。
// 这条钉住的是分工:哪天有人把中间件里那行归一挪走,鉴权就漏了,而这里仍然该是 undefined。
expect(registry.lookup('HEAD', '/api/gov/tasks')).toBeUndefined();
expect(registry.lookup('GET', '/api/gov/tasks')).toBe('ops');
});
it('数组归属原样回', () => {
expect(registry.lookup('GET', '/api/gov/staff')).toEqual(['design', 'ops']);
});
it('has 认的是注册时的模版,lookup 认的是实际路径', () => {
expect(registry.has('POST', '/api/gov/tasks/:taskId/control')).toBe(true);
expect(registry.has('GET', '/api/gov/tasks/:taskId/control')).toBe(false);
expect(registry.has('GET', '/api/health')).toBe(true);
expect(registry.size).toBe(9);
});
it('参数名换一个:lookup 还是能匹配(参数段认任意值),has 就说没有', () => {
// 注册的模版是 `:taskId`,lookup('/api/gov/tasks/:otherName/control') 的
// `:otherName` 满足参数段的 [^/]+ 正则,lookup 会命中;
// 但 has 只认注册时的模版名,两者语义不同
expect(registry.lookup('POST', '/api/gov/tasks/:otherName/control')).toBe('ops');
expect(registry.has('POST', '/api/gov/tasks/:otherName/control')).toBe(false);
});
});
describe('字面优先于参数段 —— 同一个路径两边都能匹配时', () => {
const collide = buildRouteRegistry([
{ method: 'GET', path: '/api/gov/:zone', access: 'design' },
{ method: 'GET', path: '/api/gov/staff', access: ['design', 'ops'] },
]);
it('字面赢', () => {
expect(collide.lookup('GET', '/api/gov/staff')).toEqual(['design', 'ops']);
});
it('没有字面可命中时才落到参数段', () => {
expect(collide.lookup('GET', '/api/gov/tools')).toBe('design');
});
});
describe('routesOf —— 从契约命名空间挑路由', () => {
it('只认长得像路由契约的导出', () => {
expect(isRouteContract(contracts.meRoute)).toBe(true);
expect(isRouteContract(contracts.staffSchema)).toBe(false);
expect(isRouteContract('x')).toBe(false);
expect(isRouteContract(null)).toBe(false);
});
it('扫真契约至少 90 条,且每条都能 has 到自己', () => {
const all = routesOf(Object.values(contracts));
expect(all.length).toBeGreaterThanOrEqual(90);
const reg = buildRouteRegistry(all);
for (const r of all) expect(reg.has(r.method, r.path), `${r.method} ${r.path}`).toBe(true);
});
});
describe('建表自检 —— 两条模版能撞就不许建', () => {
const reg = (routes: Parameters<typeof buildRouteRegistry>[0]) => () => buildRouteRegistry(routes);
it('同方法、同段数、逐位兼容 → 抛错', () => {
expect(
reg([
{ method: 'GET', path: '/api/a/:x', access: 'ops' },
{ method: 'GET', path: '/api/:y/b', access: 'design' },
]),
).toThrow(/同一个具体路径/);
});
it('方法不同不算撞', () => {
expect(
reg([
{ method: 'GET', path: '/api/a/:x', access: 'ops' },
{ method: 'POST', path: '/api/:y/b', access: 'design' },
]),
).not.toThrow();
});
it('段数不同不算撞', () => {
expect(
reg([
{ method: 'GET', path: '/api/a/:x', access: 'ops' },
{ method: 'GET', path: '/api/:y/b/c', access: 'design' },
]),
).not.toThrow();
});
it('字面段对不上不算撞', () => {
expect(
reg([
{ method: 'GET', path: '/api/a/:x', access: 'ops' },
{ method: 'GET', path: '/api/b/:y', access: 'design' },
]),
).not.toThrow();
});
it('字面与参数并存不算撞 —— 那是有意的优先级', () => {
expect(
reg([
{ method: 'GET', path: '/api/gov/:zone', access: 'design' },
{ method: 'GET', path: '/api/gov/staff', access: 'ops' },
]),
).not.toThrow();
});
it('同一条字面路由注册两遍 → 抛(后写的会静默盖掉先写的)', () => {
expect(
reg([
{ method: 'GET', path: '/api/gov/staff', access: 'ops' },
{ method: 'GET', path: '/api/gov/staff', access: 'design' },
]),
).toThrow(/注册了两遍/);
});
it('同路径不同方法照旧不算撞', () => {
expect(
reg([
{ method: 'GET', path: '/api/gov/staff', access: 'ops' },
{ method: 'POST', path: '/api/gov/staff', access: 'ops' },
]),
).not.toThrow();
});
it('真实 95 条契约路由建得起来(没有暗撞)', () => {
expect(() => buildRouteRegistry(routesOf(Object.values(contracts)))).not.toThrow();
});
});
@@ -77,12 +77,14 @@ beforeAll(async () => {
await pool.query(`create schema "${schema}"`);
}
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url }));
container = await createContainer(
loadConfig({ ...process.env, DATABASE_URL: database.url, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }),
);
await container.org.ensureSeeded();
await seedMvp(container);
const { staff } = await container.org.listStaff();
chaihao = staff.find((s) => s.account === 'Chaihao')!.id;
ai = staff.find((s) => s.account === 'AI')!.id;
// 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己)
chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id;
ai = (await container.org.registerIdentity({ sub: 'it-ai', account: 'ai', name: 'AI', roles: ['admin'] })).id;
}, 180_000);
afterAll(async () => {
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { TICKET_TTL_MS, createTicketIssuer } from '../ticket.ts';
const issuer = createTicketIssuer(Buffer.alloc(32, 7));
const t0 = new Date('2026-08-25T09:00:00Z');
describe('60 秒票据', () => {
it('签出来能验回 sub;形状 v1.<sub>.<exp>.<mac>;到期时刻 = 签发 + 60s', () => {
const { ticket, expiresAt } = issuer.issue('sub-1', t0);
expect(ticket.split('.')).toHaveLength(4);
expect(ticket.startsWith('v1.')).toBe(true);
expect(expiresAt.getTime() - t0.getTime()).toBe(TICKET_TTL_MS);
expect(issuer.verify(ticket, new Date(t0.getTime() + 30_000))).toEqual({ staffId: 'sub-1' });
});
it('到期即拒(61 秒后)', () => {
const { ticket } = issuer.issue('sub-1', t0);
expect(issuer.verify(ticket, new Date(t0.getTime() + TICKET_TTL_MS + 1000))).toBeUndefined();
});
it('改一个字就拒:mac / sub / exp', () => {
const { ticket } = issuer.issue('sub-1', t0);
const [v, id, exp, mac] = ticket.split('.') as [string, string, string, string];
expect(issuer.verify(`${v}.${id}.${exp}.${mac.slice(0, -1)}${mac.endsWith('A') ? 'B' : 'A'}`, t0)).toBeUndefined();
expect(issuer.verify(`${v}.${Buffer.from('sub-2').toString('base64url')}.${exp}.${mac}`, t0)).toBeUndefined();
expect(issuer.verify(`${v}.${id}.${Number(exp) + 600}.${mac}`, t0)).toBeUndefined();
});
it('别的密钥签的不认', () => {
const other = createTicketIssuer(Buffer.alloc(32, 8));
expect(issuer.verify(other.issue('sub-1', t0).ticket, t0)).toBeUndefined();
});
it('垃圾输入不抛只拒', () => {
expect(issuer.verify('', t0)).toBeUndefined();
expect(issuer.verify('v2.a.b.c', t0)).toBeUndefined();
expect(issuer.verify('a.b', t0)).toBeUndefined();
expect(issuer.verify('v1...', t0)).toBeUndefined();
});
});
@@ -88,12 +88,14 @@ beforeAll(async () => {
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
// 配置指向测试库 —— 经 loadConfig 的 env 形参,不污染 process.env
container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url }));
container = await createContainer(
loadConfig({ ...process.env, DATABASE_URL: database.url, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }),
);
await container.org.ensureSeeded();
await seedMvp(container);
const { staff } = await container.org.listStaff();
chaihao = staff.find((s) => s.account === 'Chaihao')!.id;
// 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己)
chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id;
}, 180_000);
afterAll(async () => {
+81 -28
View File
@@ -10,6 +10,9 @@
* 不存这一份,试跑单一放行,人在工作成果里就什么都点不开了。
*
* 一单一行一份,重复放行覆盖(不留两份说法不一的档)。
*
* **「这一单产出的」认的是本单碰过的行**,不是这张表里现有的所有行 ——
* 见 `assetRowsOf` 的病根记档。
*/
import { sql } from 'drizzle-orm';
import type { TaskId } from '@contracts';
@@ -35,6 +38,71 @@ function rowsOf<T>(result: unknown): T[] {
return Array.isArray(rows) ? (rows as T[]) : [];
}
/** 一张台账表的声明 —— 带 `file` 段的才产资料;这份声明同时是授权面 */
export type AssetDeclaration = {
readonly domain: string;
readonly object: string;
readonly fields: ReadonlyArray<{ name: string; primaryKey?: boolean }>;
readonly file?: { titleColumn: string; bodyColumn: string; formatColumn: string } | undefined;
};
/** 本单碰过的一行(`ledger.taskDrafts()` 那一份的形状) */
export type TouchedKey = {
readonly domain: string;
readonly object: string;
readonly key: Readonly<Record<string, unknown>>;
};
/** 这一单产出的一份资料 —— 还没造成字节的那一步 */
export type AssetRow = {
readonly address: string;
readonly key: Record<string, string>;
readonly title: string;
readonly format: FileFormat;
readonly markdown: string;
};
/**
* 这一单**产出的**资料行 —— 存档与工作成果两处同一段,不各写一份。
*
* 「产出的」认的是**本单碰过的行**(草稿键),不是这张表里现有的所有行。
* 病根记档(2026-08-25 柴浩实测:「工作成果怎么有 4 个资料,这单只会产出一个」):
* 两处从前都拿 `taskView` 的整页,而那是「正式版全表 + 本单草稿」的合并视图 ——
* 资料常落在**主数据**上(品牌内容库 `content.asset`,历次跑出来的资产都躺在里面),
* 于是一单产 1 份、工作成果里摆 4 份:前三份是别单的成果,冒充这一单交出去的东西。
* 台账契约本就把这两件事分得很清楚(`taskDrafts` 的注释:「引擎求值要区分
* 『这一单的事实』与『别人放行后的事实』」),视图模版也一律绑 `mine:` ——
* 只有资料这两处忘了。
*
* 值仍取合并视图那一份(正式现值 + 本单草稿叠加):改过一格的行要按
* 「落库后的样子」造文件,只拿草稿会缺掉这轮没碰过的那几列(与 `touched:` 同一道理)。
*/
export function assetRowsOf(
declared: AssetDeclaration,
rows: ReadonlyArray<Record<string, unknown>>,
touched: ReadonlyArray<TouchedKey>,
): AssetRow[] {
const spec = declared.file;
if (spec === undefined) return []; // 没声明 `file` 段的表不产资料 —— 存档与列表都认这道授权面
const keys = declared.fields.filter((f) => f.primaryKey === true).map((f) => f.name);
if (keys.length === 0) return [];
const mine = touched.filter((t) => t.domain === declared.domain && t.object === declared.object);
const address = `${declared.domain}.${declared.object}`;
const out: AssetRow[] = [];
for (const row of rows) {
const key: Record<string, string> = {};
for (const k of keys) key[k] = String(row[k] ?? '');
if (Object.values(key).some((v) => v === '')) continue; // 主键不全的行认不出来,存了也取不回
if (!mine.some((t) => keys.every((k) => String(t.key[k] ?? '') === key[k]))) continue; // 别单的成果不算这一单的
const markdown = String(row[spec.bodyColumn] ?? '').trim();
if (markdown === '') continue; // 没正文的行不是资料,别摆一份空壳
const title = String(row[spec.titleColumn] ?? '').trim() || Object.values(key).join('-');
const rawFormat = String(row[spec.formatColumn] ?? '').trim().toLowerCase();
out.push({ address, key, title, format: isFileFormat(rawFormat) ? rawFormat : 'md', markdown });
}
return out;
}
/**
* 主键 → 一个**稳定的**字符串(按列名排序后拼)。
* 稳定是要害:存的时候与取的时候拼法不一样,那一行就永远对不上。
@@ -111,7 +179,7 @@ export async function readArchived(
* 把这一单产出的资料**全部存一份**。放行落地之前调 —— 那时草稿还在、人的修订也已落回,
* 存下来的正是他签字那一刻的样子。
*
* 返回存了几份;一份都没有(活动没声明 `file` 段、或一行都没写)时返回 0,不报错 ——
* 返回存了几份;一份都没有(活动没声明 `file` 段、或这一单一行也没写)时返回 0,不报错 ——
* 大多数活动本来就不产资料文件。
*/
export async function archiveAssetFiles(input: {
@@ -119,40 +187,25 @@ export async function archiveAssetFiles(input: {
readonly taskId: TaskId;
readonly ids: { next(): string };
/** 活动清单声明的台账对象(带 `file` 段的才算) */
readonly declarations: ReadonlyArray<{
domain: string;
object: string;
fields: ReadonlyArray<{ name: string; primaryKey?: boolean }>;
file?: { titleColumn: string; bodyColumn: string; formatColumn: string } | undefined;
}>;
/** 本单在该地址写下的行(正式版 + 本单草稿) */
readonly declarations: ReadonlyArray<AssetDeclaration>;
/**
* **本单碰过的行** —— `ledger.taskDrafts()` 那一份。
* 「哪几行是这一单产出的」只有这一个答案:表里现有的行不作数(见 `assetRowsOf`)。
*/
readonly touched: ReadonlyArray<TouchedKey>;
/** 该地址的合并行(正式现值 + 本单草稿)—— 值按「落库后的样子」造文件 */
readonly rowsOfAddress: (address: { domain: string; object: string }) => Promise<Array<Record<string, unknown>>>;
}): Promise<number> {
let saved = 0;
for (const declared of input.declarations) {
const spec = declared.file;
if (spec === undefined) continue;
const keys = declared.fields.filter((f) => f.primaryKey === true).map((f) => f.name);
if (keys.length === 0) continue;
if (declared.file === undefined) continue; // 整张表不产资料,连取行都免了
const rows = await input.rowsOfAddress({ domain: declared.domain, object: declared.object });
for (const row of rows) {
const markdown = String(row[spec.bodyColumn] ?? '').trim();
if (markdown === '') continue; // 没正文的行不是资料,别存一份空壳
const key: Record<string, string> = {};
for (const k of keys) key[k] = String(row[k] ?? '');
if (Object.values(key).some((v) => v === '')) continue; // 主键不全的行认不出来,存了也取不回
const title = String(row[spec.titleColumn] ?? '').trim() || Object.values(key).join('-');
const rawFormat = String(row[spec.formatColumn] ?? '').trim().toLowerCase();
const format: FileFormat = isFileFormat(rawFormat) ? rawFormat : 'md';
const composed = composeFile({ title, markdown, format });
const address = `${declared.domain}.${declared.object}`;
for (const asset of assetRowsOf(declared, rows, input.touched)) {
const composed = composeFile({ title: asset.title, markdown: asset.markdown, format: asset.format });
await input.db.execute(sql`
insert into sys.task_asset_file (id, task_id, address, row_key, title, format, name, bytes, size)
values (${input.ids.next()}, ${input.taskId}, ${address}, ${JSON.stringify(key)}::jsonb,
${title}, ${format}, ${composed.fileName}, ${Buffer.from(composed.bytes)}, ${composed.bytes.length})
values (${input.ids.next()}, ${input.taskId}, ${asset.address}, ${JSON.stringify(asset.key)}::jsonb,
${asset.title}, ${asset.format}, ${composed.fileName}, ${Buffer.from(composed.bytes)}, ${composed.bytes.length})
on conflict (task_id, address, row_key) do update set
title = excluded.title, format = excluded.format, name = excluded.name,
bytes = excluded.bytes, size = excluded.size, created_at = now()`);
+111
View File
@@ -0,0 +1,111 @@
/**
* 鉴权中间件(ADR-012)—— app 层唯一一处;身份判定读 org 出口(ADR-010 的分工不变)。
*
* ① 查路由注册表拿 access;public 直接放行。
* ② 取凭据:Authorization: Bearer;GET 且没带头时认 ?ticket=(EventSource / <a href> 的路)。
* 方法先归一(HEAD 按 GET),再查表、再判票据 —— 两处都按分发时的方法算。
* ③ Bearer → org.authenticate(验签 + 名册即时更新 + 角色→端);票据 → org.principalOf(从库现算)。
* ④ 401 = 没凭据 / 凭据坏了(该重新登录);403 = 人对了但进不了这个端 / 账号停用(重新登录没用)。
* 401 与 403 不混用 —— 前端靠它分辨「该重新登录」和「没权限」(手册 4.5)。
*/
import type { MiddlewareHandler } from 'hono';
import type { ApiError, Principal, RouteAccess, StaffId, Surface } from '@contracts';
import { SURFACE_LABEL, staffIdSchema } from '@contracts';
import { AccountDisabledError, TokenRejectedError } from '@modules/org';
import type { AppEnv } from './http-env.ts';
import type { RouteRegistry } from './route-registry.ts';
import type { TicketIssuer } from './ticket.ts';
/** org 出口里中间件用到的那两口 —— 窄接口,测试好伪造 */
export type Authenticator = {
authenticate(token: string): Promise<Principal>;
principalOf(staffId: StaffId): Promise<Principal | undefined>;
};
export type AuthMiddlewareDeps = {
readonly registry: RouteRegistry;
readonly org: Authenticator;
readonly tickets: TicketIssuer;
readonly clock: { now(): Date };
};
export function bearerOf(header: string | undefined): string | undefined {
if (header === undefined) return undefined;
const match = /^bearer\s+(\S+)\s*$/i.exec(header.trim());
return match?.[1];
}
/** access 要求的端;public / any 不要求 */
export function requiredEnds(access: RouteAccess): readonly Surface[] | undefined {
if (access === 'public' || access === 'any') return undefined;
return typeof access === 'string' ? [access] : access;
}
export function forbiddenBody(need: readonly Surface[], principal: Principal): ApiError {
const needText = need.map((s) => `「${SURFACE_LABEL[s]}」`).join(' / ');
const roles = principal.roles.length === 0 ? '无' : principal.roles.join(', ');
const can = principal.ends.length === 0 ? '无端可进' : principal.ends.map((s) => SURFACE_LABEL[s]).join('、');
return {
code: 'forbidden',
message: `进入${needText}需要授权;你的角色(${roles})可进:${can}`,
detail: { need: [...need], ends: [...principal.ends] },
};
}
const unauthenticated = (message: string): ApiError => ({ code: 'unauthenticated', message });
export function createAuthMiddleware(deps: AuthMiddlewareDeps): MiddlewareHandler<AppEnv> {
return async (c, next) => {
c.set('principal', undefined);
/*
* HEAD 按 GET 判。Hono 在 #dispatch 里把 HEAD 改写成 GET 再分发,而 `c.req.method`
* 读的是请求行上的原始方法 —— 不归一的话 lookup 必然 miss,落进 `?? 'any'` 兜底,
* 于是「只要登录就能跑任何 GET 处理器」。**凭据与归属一律按分发时的方法判,
* 不按请求行上的字面方法。**
*/
const method = c.req.method === 'HEAD' ? 'GET' : c.req.method;
// 未知路由按 any 处置:先鉴权再 404,与从前一致(不给没登录的人探路由表)
const access = deps.registry.lookup(method, c.req.path) ?? 'any';
if (access === 'public') {
await next();
return undefined;
}
const bearer = bearerOf(c.req.header('authorization'));
const ticket = method === 'GET' ? c.req.query('ticket') : undefined;
if (bearer === undefined && (ticket === undefined || ticket === '')) {
return c.json(unauthenticated('缺少 Bearer token'), 401);
}
let principal: Principal;
try {
if (bearer !== undefined) {
principal = await deps.org.authenticate(bearer);
} else {
const opened = deps.tickets.verify(ticket ?? '', deps.clock.now());
if (opened === undefined) {
return c.json(unauthenticated('票据无效或已过期 —— 重新取一张 GET /api/auth/ticket'), 401);
}
const found = await deps.org.principalOf(staffIdSchema.parse(opened.staffId));
if (found === undefined) return c.json(unauthenticated('票据对应的员工不存在'), 401);
principal = found;
}
} catch (error) {
if (error instanceof TokenRejectedError) return c.json(unauthenticated(error.message), 401);
if (error instanceof AccountDisabledError) {
const body: ApiError = { code: 'forbidden', message: error.message };
return c.json(body, 403);
}
throw error;
}
const need = requiredEnds(access);
if (need !== undefined && !need.some((s) => principal.ends.includes(s))) {
return c.json(forbiddenBody(need, principal), 403);
}
c.set('principal', principal);
await next();
return undefined;
};
}
+50 -8
View File
@@ -3,6 +3,18 @@
*/
import { join } from 'node:path';
/** 统一鉴权中心(ADR-012)。本服务只验不调,**不配 client secret**(手册 13.2) */
export type KeycloakConfig = {
readonly url: string;
readonly realm: string;
/** 校 aud 用 —— 填错全线 401 */
readonly clientId: string;
readonly issuer: string;
readonly jwksUrl: string;
/** master realm 的 provisioning 服务账号(只读名册,view-users);可选 */
readonly provisioning?: { readonly clientId: string; readonly secret: string };
};
export type AppConfig = {
readonly nodeEnv: 'development' | 'test' | 'production';
readonly port: number;
@@ -11,25 +23,55 @@ export type AppConfig = {
readonly attachmentRoot: string;
/** 派发器轮询间隔(毫秒) */
readonly dispatchIntervalMs: number;
/**
* 播种口令(ADR-010)—— 新建员工的初始口令。
* 源码不写任何真实口令;生产部署必须经环境变量覆盖缺省值。
*/
readonly seedPassword: string;
/** 邮件轮询工作者的 tick(毫秒);每个账号自己的间隔另算 */
readonly mailPollTickMs: number;
/** 三样齐了才有;缺一样即 undefined —— main.ts 据此拒绝启动,集成测试可不配 */
readonly keycloak?: KeycloakConfig;
/** 测试账号(Keycloak 用户名):首次建档 / 播种时落 TEST 岗 + 独立工作组 */
readonly testAccounts: readonly string[];
};
const DEFAULT_DATABASE_URL = 'postgres://jcai:jcai@localhost:54329/jcai_wos';
/** 开发缺省口令 —— 播种时在 stdout 明示,生产须以 `JCAI_SEED_PASSWORD` 覆盖 */
const DEFAULT_SEED_PASSWORD = 'jcai-dev-2026';
function nonEmpty(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed === undefined || trimmed === '' ? undefined : trimmed;
}
function keycloakOf(env: NodeJS.ProcessEnv): KeycloakConfig | undefined {
const url = nonEmpty(env['KEYCLOAK_URL'])?.replace(/\/+$/, '');
const realm = nonEmpty(env['KEYCLOAK_REALM']);
const clientId = nonEmpty(env['KEYCLOAK_CLIENT_ID']);
if (url === undefined || realm === undefined || clientId === undefined) return undefined;
const issuer = `${url}/realms/${realm}`;
const provisioningId = nonEmpty(env['KEYCLOAK_PROVISIONING_CLIENT_ID']);
const provisioningSecret = nonEmpty(env['KEYCLOAK_PROVISIONING_CLIENT_SECRET']);
return {
url,
realm,
clientId,
issuer,
jwksUrl: `${issuer}/protocol/openid-connect/certs`,
...(provisioningId === undefined || provisioningSecret === undefined
? {}
: { provisioning: { clientId: provisioningId, secret: provisioningSecret } }),
};
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const nodeEnv = env['NODE_ENV'];
const keycloak = keycloakOf(env);
return {
nodeEnv: nodeEnv === 'production' || nodeEnv === 'test' ? nodeEnv : 'development',
port: Number(env['PORT'] ?? 8787),
databaseUrl: env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL,
attachmentRoot: env['JCAI_ATTACHMENT_ROOT'] ?? join(process.cwd(), '.data', 'attachments'),
dispatchIntervalMs: Number(env['JCAI_DISPATCH_INTERVAL_MS'] ?? 2000),
seedPassword: env['JCAI_SEED_PASSWORD'] ?? DEFAULT_SEED_PASSWORD,
mailPollTickMs: Number(env['JCAI_MAIL_POLL_TICK_MS'] ?? 5000),
...(keycloak === undefined ? {} : { keycloak }),
testAccounts: (env['JCAI_TEST_ACCOUNTS'] ?? '')
.split(',')
.map((s) => s.trim())
.filter((s) => s !== ''),
};
}
+116 -29
View File
@@ -16,18 +16,20 @@ import { createEventService, createEventStore } from '@modules/event';
import type { LedgerService } from '@modules/ledger';
import type { LoopEngineService } from '@modules/loop-engine';
import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger';
import type { OrgService, InFlightWorkProbe } from '@modules/org';
import type { InFlightWorkProbe, OrgService, RosterSource, TokenVerifier } from '@modules/org';
import {
createGrantRepository,
createKeycloakRoster,
createKeycloakTokenVerifier,
createOrgService,
createPositionRepository,
createSessionRepository,
createRoleAccessRepository,
createStaffRepository,
createWorkGroupRepository,
hashPassword,
newSessionToken,
verifyPassword,
TokenRejectedError,
} from '@modules/org';
import { createRemoteJWKSet } from 'jose';
import { createTicketIssuer } from './ticket.ts';
import type { TicketIssuer } from './ticket.ts';
import type { LlmService, ModelGateway } from '@modules/llm';
import {
createLlmInstanceRepository,
@@ -72,12 +74,19 @@ import type { TraceService } from '@modules/trace';
import { wireEngines } from './wiring/engines.ts';
import { wireDeskElements } from './wiring/desk-elements.ts';
import type { ChannelService } from '@modules/channel';
import { createChannelRepository, createChannelService, createFileAttachmentArchive } from '@modules/channel';
import type { MailService } from '@modules/mail';
import {
createChannelRepository,
createChannelService,
createFileAttachmentArchive,
createSimulatedMailTransport,
} from '@modules/channel';
createImapSmtpProvider,
createInboundStore,
createMailAccountStore,
createMailService,
createOutboundStore,
createPostalMimeParser,
createWecomProvider,
} from '@modules/mail';
import { channelSubscriber, channelTransport, mailSendTool } from './mail-wiring.ts';
import { readUpload, storeUpload } from './upload-store.ts';
import type { AppConfig } from './config.ts';
import { createIdGenerator, systemClock } from './runtime.ts';
@@ -94,6 +103,8 @@ export type Container = {
readonly pool: Pool;
readonly db: Db;
readonly org: OrgService;
/** 60 秒票据(ADR-012 §9.3)—— 给 EventSource 与 <a href> 下载这两条带不了 Authorization 头的路 */
readonly tickets: TicketIssuer;
readonly ledger: LedgerService;
readonly event: EventService;
readonly llm: LlmService;
@@ -103,6 +114,8 @@ export type Container = {
readonly toolInvoker: ToolInvoker;
readonly message: MessageService;
readonly channel: ChannelService;
/** 邮件收发(ADR-013):账号 / 轮询 / 订阅 / 发信 */
readonly mail: MailService;
readonly humanTool: HumanToolService;
readonly interaction: InteractionService;
readonly registry: ActivityRegistryService;
@@ -168,10 +181,13 @@ const sfeelEvaluator = (expression: string, scope: Readonly<Record<string, unkno
}
};
/** 第一方工具实现 —— 由 app 注册进 ToolRuntime;活动只见调用契约 */
export const firstPartyToolHandlers = new Map<string, ToolHandler>(FIRST_PARTY_TOOLS);
/** 集成测试的注入缝:验签与名册来源都是外界,测试给替身,不联网 */
export type ContainerOverrides = {
readonly tokenVerifier?: TokenVerifier;
readonly roster?: RosterSource;
};
export async function createContainer(config: AppConfig): Promise<Container> {
export async function createContainer(config: AppConfig, overrides: ContainerOverrides = {}): Promise<Container> {
/*
* 广播器**先建** —— 装配期就要往它上面接线。
*
@@ -197,25 +213,60 @@ export async function createContainer(config: AppConfig): Promise<Container> {
*/
const bridge = bridgeBroadcaster(pool, broadcaster);
const ids = createIdGenerator();
const secrets = createSecretBox(loadSecretKey());
const masterKey = loadSecretKey();
const secrets = createSecretBox(masterKey);
const tickets = createTicketIssuer(masterKey);
// 草稿层的建表:ledger schema 的唯一写者是 ledger adapter,DDL 在其出口给出
await pool.query(DRAFT_DDL);
const inFlightProbe = createDeferredInFlightProbe();
/*
* 身份归统一鉴权中心(ADR-012):验签走 jose 远程 JWKS(首次用到才拉、缓存 10 分钟、
* 30 秒冷却、5 秒超时 —— 手册 4.1);没配 KEYCLOAK_* 就装一个恒拒的 verifier:
* 集成测试走用例不走 HTTP,不受影响;真跑起来缺配置的话 main.ts 早就拒绝启动了。
*/
const keycloak = config.keycloak;
const verifier: TokenVerifier =
overrides.tokenVerifier ??
(keycloak === undefined
? {
verify: async () => {
throw new TokenRejectedError('未配置 KEYCLOAK_URL / KEYCLOAK_REALM / KEYCLOAK_CLIENT_ID');
},
}
: createKeycloakTokenVerifier({
issuer: keycloak.issuer,
audience: keycloak.clientId,
getKey: createRemoteJWKSet(new URL(keycloak.jwksUrl), {
cooldownDuration: 30_000,
cacheMaxAge: 10 * 60_000,
timeoutDuration: 5_000,
}),
}));
const roster: RosterSource | undefined =
overrides.roster ??
(keycloak?.provisioning === undefined
? undefined
: createKeycloakRoster({
url: keycloak.url,
realm: keycloak.realm,
clientId: keycloak.provisioning.clientId,
secret: keycloak.provisioning.secret,
}));
const org = createOrgService({
verifier,
...(roster === undefined ? {} : { roster }),
positions: createPositionRepository(db),
staff: createStaffRepository(db),
grants: createGrantRepository(db),
sessions: createSessionRepository(db),
roleAccess: createRoleAccessRepository(db),
workGroups: createWorkGroupRepository(db),
// 散列算法在 adapter,usecase 只认这三个动作(ADR-010)
passwords: { hash: hashPassword, verify: verifyPassword, newToken: newSessionToken },
inFlight: inFlightProbe,
clock: systemClock,
seedPassword: config.seedPassword,
ids,
testAccounts: config.testAccounts,
});
// message 是 event 的 `MessageFallback` 实现面 —— event(L1)不反向依赖它,由此处注入
@@ -226,15 +277,20 @@ export async function createContainer(config: AppConfig): Promise<Container> {
const { staff } = await org.listStaff();
return staff.filter((s) => s.positionId === positionId).map((s) => ({ id: s.id }));
},
/*
* 治理台收件人 = 能进运营中心的活跃员工(任务在那儿管;ADR-012 权限只到端)。
* **空集要出声**:全新安装、还没人以 admin 登录过时这里就是空的,而调用方是死信通知与
* 无主入站兜底 —— 收件人为空就等于把「出事了」这件事无声丢掉(铁规矩 8:界面不撒谎)。
*/
governanceStaff: async () => {
const { staff } = await org.listStaff();
const governing = await Promise.all(
staff.map(async (s) => ({
s,
ok: await org.authorize(s.id, { kind: 'governance', function: 'task' }, 'govern'),
})),
const recipients = await org.staffWithEnd('ops');
if (recipients.length === 0) {
process.stderr.write(
'[org] 没有能进运营中心的活跃员工 —— 死信与无主入站的通知这一次没有收件人。' +
'去运营中心的「角色 → 端」把某个 realm 角色配上 ops,或让持该角色的人先登录一次。\n',
);
return governing.filter((g) => g.ok).map((g) => ({ id: g.s.id }));
}
return recipients.map((s) => ({ id: s.id }));
},
},
clock: systemClock,
@@ -319,9 +375,36 @@ export async function createContainer(config: AppConfig): Promise<Container> {
ids,
});
/*
* 邮件收发(ADR-013)—— L1,只要库 / 密封 / 时钟;先于工具运行时装配,`mail.send` 的处理器要闭包持它。
* 变更经 ChangeNotifier 接到广播:轮询器不走 /api/gov/* 中间件,新来信 / 拉取出错得自己说一声。
* 附件落 sys.upload —— 与上传路由同一段存取(8MB 上限、文本抽取、下载路由一并复用)。
*/
const mail = createMailService({
accounts: createMailAccountStore(db),
inbound: createInboundStore(db),
outbound: createOutboundStore(db),
attachments: {
put: async (file) => ({
ref: (await storeUpload(db, { name: file.name, contentType: file.contentType, bytes: file.bytes, uploadedBy: null, note: file.note })).ref,
}),
get: (ref) => readUpload(db, ref),
},
providers: { 'imap-smtp': createImapSmtpProvider(), wecom: createWecomProvider() },
mime: createPostalMimeParser(),
secrets,
clock: systemClock,
ids,
notify: { changed: (reason) => broadcaster.publish({ kind: 'system', id: 'mail', reason }) },
});
const toolRegistry = createToolRegistry(db);
/** 第一方工具实现 —— `mail.send` 真发信,处理器在这儿闭包持 mail(放行时经 ToolInvoker 执行) */
// 导入的自定义工具走通用 HTTP 实现:契约申报 endpoint,运行时只把那一个连接目标交出去
const toolRuntime = createToolRuntime(firstPartyToolHandlers, httpToolHandler);
const toolRuntime = createToolRuntime(
new Map<string, ToolHandler>([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]),
httpToolHandler,
);
const schemas = createSchemaValidator();
const toolbox = createToolboxService({
registry: toolRegistry,
@@ -349,11 +432,13 @@ export async function createContainer(config: AppConfig): Promise<Container> {
recordOutbound: (ref) => event.recordOutbound(ref),
},
ledger: { systemWrite: (input) => ledger.systemWrite(input) },
mail: createSimulatedMailTransport(db, ids),
mail: channelTransport(mail),
archive: createFileAttachmentArchive(config.attachmentRoot),
clock: systemClock,
ids,
});
// channel 是第一个订阅方:来信归一进事件流,活动经触发器 / 监听收到;账号没绑通道的信留在运营面
mail.subscribe('channel', channelSubscriber(channel));
/* ── L3/L4:交互、人类工具、活动库、放行门 ─────────────────────── */
@@ -715,6 +800,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
pool,
db,
org,
tickets,
ledger,
event,
llm,
@@ -724,6 +810,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
toolInvoker,
message,
channel,
mail,
humanTool,
interaction,
registry,
+14 -5
View File
@@ -1,11 +1,20 @@
/**
* Hono 的请求上下文变量 —— 单独成文件,避免 server 与 routes 互相 import 成环(§0.1 第 3 条)。
*/
import type { Session } from '@contracts';
import type { Context } from 'hono';
import type { Position, Principal, Staff } from '@contracts';
/**
* 请求上下文(ADR-010)。
* `session` 由鉴权中间件从**服务端会话表**解出 —— 不再有「cookie 里写谁就是谁」的明文身份;
* `account` 保留为便捷读法,值恒取自已核验的会话,免鉴权路由上为空串。
* 请求上下文(ADR-012)。`principal` 由鉴权中间件从 Bearer / 票据解出 ——
* 身份只能来自验过签的 token,不再有「cookie 里写谁就是谁」。
*/
export type AppEnv = { Variables: { account: string; session: Session | undefined } };
export type AppEnv = { Variables: { principal: Principal | undefined } };
/**
* 当前登录者(连同岗位)。非公开路由上中间件已保证存在;公开路由上是 undefined ——
* 调用方照旧判一下 undefined,免得哪天有人把一条路由改成 public 还在读它。
*/
export function staffOf(c: Context<AppEnv>): (Staff & { readonly position: Position }) | undefined {
const principal = c.get('principal');
return principal === undefined ? undefined : { ...principal.staff, position: principal.position };
}
+22
View File
@@ -0,0 +1,22 @@
/**
* 启动自检(手册第 5 步;ADR-012):拉 `.well-known/openid-configuration`,
* issuer 与本地拼出来的不一致就别起 —— 配错了每个请求都 401 还查不出原因,不如启动时就说清楚。
*/
import type { KeycloakConfig } from './config.ts';
export async function checkKeycloakConnection(
kc: KeycloakConfig,
fetchImpl: typeof fetch = fetch,
): Promise<{ issuer: string; clientId: string }> {
const url = `${kc.issuer}/.well-known/openid-configuration`;
const resp = await fetchImpl(url, { signal: AbortSignal.timeout(5000) });
if (!resp.ok) throw new Error(`拉取发现文档失败 (${resp.status}): ${url}`);
const conf = (await resp.json()) as { issuer?: unknown };
if (conf.issuer !== kc.issuer) {
throw new Error(
`issuer 不一致!\n 期望: ${kc.issuer}\n 实际: ${String(conf.issuer)}\n` +
' → Keycloak 的 KC_HOSTNAME 配错了,或本服务的 KEYCLOAK_URL 写错了',
);
}
return { issuer: kc.issuer, clientId: kc.clientId };
}
+158
View File
@@ -0,0 +1,158 @@
/**
* mail 与 channel / toolbox 的接线(app 层;设计稿 §8;ADR-013):
* ① channel 订阅方:入站 → channel.ingestMail(账号绑的通道)—— 活动经现有事件链收到信
* ② channel 的 MailTransport:出站 → mail.send(按 from 找账号,对不上不回落默认)
* ③ `mail.send` 工具处理器:活动侧出站,放行时经 ToolInvoker 执行;dryRun 只校验不发(注册试运行会真调处理器)
* 三段都是纯映射 + 一次调用,单测直接对函数。
*/
import type { InboundMail, MailSendInput } from '@contracts';
import type { MailTransport } from '@modules/channel';
import type { InboundHandler, MailInboundDetail, MailService } from '@modules/mail';
import {
bodyTextOf,
MailAccountDisabledError,
MailAccountNotFoundError,
MailNoDefaultAccountError,
MailSecretMissingError,
MailSendInvalidError,
threadIdOf,
} from '@modules/mail';
import type { ToolHandler } from '@modules/toolbox';
/**
* mail 的入站详情 → channel 的 InboundMail:只带存下来的附件。
*
* **线程口径只有一处**:`threadIdOf`(mail 模块的 domain,取这一串的根;缘由与反例见那里)。
* 这里曾经内联过一份同义实现,而模块里那一份当时取的是最近一跳 —— 同一个模块两套相反的语义,
* 下一个人照导出的那个接线就会接反。现在调它。
*/
export function toInboundMail(mail: MailInboundDetail): InboundMail {
const threadId = threadIdOf(mail);
return {
messageId: mail.messageId,
...(threadId === undefined ? {} : { threadId }),
from: mail.from.address,
to: mail.to.map((a) => a.address),
cc: mail.cc.map((a) => a.address),
subject: mail.subject,
body: bodyTextOf({ ...(mail.text === null ? {} : { text: mail.text }), ...(mail.html === null ? {} : { html: mail.html }) }),
receivedAt: mail.receivedAt,
attachments: mail.attachments.flatMap((a) =>
a.ref === null ? [] : [{ name: a.name, contentType: a.contentType, size: a.size, ref: a.ref }],
),
};
}
/** 只要 `ingestMail` 这一个口,出参不看(InboundOutcome 由 channel 自己留痕) */
export function channelSubscriber(channel: { ingestMail(channelId: string, mail: InboundMail): Promise<unknown> }): InboundHandler {
return async (mail, ctx) => {
if (ctx.channelId === undefined) return { skipped: '账号未绑定通道' };
await channel.ingestMail(ctx.channelId, toInboundMail(mail));
// 显式回 undefined —— 投递成功就是「没话说」;`noImplicitReturns` 不许一条路返值一条路掉出去
return undefined;
};
}
/** channel 的出站面:厂商失败抛出 —— channel.sendMail 的调用方(放行落地)要看到失败 */
export function channelTransport(mail: Pick<MailService, 'send'>): MailTransport {
return {
async send(input) {
const result = await mail.send({
from: input.from,
to: [...input.to],
cc: [...input.cc],
bcc: [],
subject: input.subject,
text: input.body,
...(input.threadId === undefined ? {} : { inReplyTo: input.threadId }),
references: input.threadId === undefined ? [] : [input.threadId],
attachments: input.attachments.map((a) => ({ name: a.name, contentType: 'application/octet-stream', uploadRef: a.ref })),
requestedBy: { kind: 'channel' },
});
if (result.state === 'failed') throw new Error(`发信失败:${result.error ?? '未知'}`);
return { messageId: result.messageId, threadId: input.threadId ?? result.messageId };
},
};
}
function recipientsOf(value: unknown): string[] {
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string').map((v) => v.trim()).filter((v) => v !== '');
if (typeof value === 'string') return value.split(/[,;]/).map((s) => s.trim()).filter((s) => s !== '');
return [];
}
function attachmentsOf(value: unknown): MailSendInput['attachments'] {
if (!Array.isArray(value)) return [];
return value.flatMap((v) => {
if (typeof v !== 'object' || v === null) return [];
const a = v as Record<string, unknown>;
const uploadRef = typeof a['uploadRef'] === 'string' && a['uploadRef'] !== '' ? a['uploadRef'] : undefined;
// 没给 uploadRef 的附件**不静默丢掉**:活动说「带上 q.pdf」,信发了附件没了还回 queued:true,是界面撒谎
if (uploadRef === undefined) throw new MailSendInvalidError([`附件 ${String(a['name'] ?? '(无名)')} 没给 uploadRef`]);
const name = typeof a['name'] === 'string' && a['name'] !== '' ? a['name'] : uploadRef;
return [{ name, contentType: 'application/octet-stream', uploadRef }];
});
}
export function mailSendTool(mail: Pick<MailService, 'send'>): ToolHandler {
return async ({ input, connection, idempotencyKey }) => {
const to = recipientsOf(input['to']);
const cc = recipientsOf(input['cc']);
const bcc = recipientsOf(input['bcc']);
const subject = typeof input['subject'] === 'string' ? input['subject'] : '';
const body = typeof input['body'] === 'string' && input['body'] !== '' ? input['body'] : undefined;
const html = typeof input['html'] === 'string' && input['html'] !== '' ? input['html'] : undefined;
const inReplyTo = typeof input['inReplyTo'] === 'string' && input['inReplyTo'] !== '' ? input['inReplyTo'] : undefined;
// 账号:入参指定 > 连接配置('default' = 默认账号)
const configured = connection['account'];
const accountId =
typeof input['accountId'] === 'string' && input['accountId'] !== ''
? input['accountId']
: configured !== undefined && configured !== '' && configured !== 'default'
? configured
: undefined;
if (input['dryRun'] === true) {
const problems = [
...(to.length === 0 ? ['收件人至少一个'] : []),
...(subject === '' ? ['主题不能为空'] : []),
...(body === undefined && html === undefined ? ['正文 body / html 至少一个'] : []),
];
return problems.length === 0
? { ok: true, output: { queued: false, dryRun: true } }
: { ok: false, error: { code: 'invalid', retryable: false, message: problems.join('; ') } };
}
try {
const result = await mail.send({
...(accountId === undefined ? {} : { accountId }),
to,
cc,
bcc,
subject,
...(body === undefined ? {} : { text: body }),
...(html === undefined ? {} : { html }),
...(inReplyTo === undefined ? {} : { inReplyTo }),
references: inReplyTo === undefined ? [] : [inReplyTo],
attachments: attachmentsOf(input['attachments']),
...(idempotencyKey === undefined ? {} : { idempotencyKey }),
requestedBy: { kind: 'tool', tool: 'mail.send' },
});
if (result.state === 'failed') {
return { ok: false, error: { code: 'send-failed', retryable: true, message: result.error ?? '发信失败' } };
}
return { ok: true, output: { queued: true, outboundId: result.outboundId, messageId: result.messageId, state: result.state } };
} catch (error) {
if (error instanceof MailSendInvalidError) return { ok: false, error: { code: 'invalid', retryable: false, message: error.message } };
if (
error instanceof MailNoDefaultAccountError ||
error instanceof MailAccountNotFoundError ||
error instanceof MailAccountDisabledError ||
error instanceof MailSecretMissingError
) {
return { ok: false, error: { code: 'no-account', retryable: false, message: error.message } };
}
throw error;
}
};
}
+20
View File
@@ -5,14 +5,30 @@
import { serve } from '@hono/node-server';
import { loadConfig } from './config.ts';
import { createContainer } from './container.ts';
import { checkKeycloakConnection } from './keycloak-check.ts';
import { createServer } from './server.ts';
import { createConsumers } from './workers/consumers.ts';
import { dispatchOnce, startDispatcher } from './workers/dispatcher.ts';
import { advanceOnce, startTaskAdvancer } from './workers/task-advancer.ts';
import { startMailPoller } from './workers/mail-poller.ts';
import { registerPulse } from './workers/pulse.ts';
export async function boot(): Promise<void> {
const config = loadConfig();
if (config.keycloak === undefined) {
process.stderr.write(
'✗ 缺 KEYCLOAK_URL / KEYCLOAK_REALM / KEYCLOAK_CLIENT_ID —— 统一鉴权中心是唯一的登录途径,缺了谁也进不来(ADR-012)\n',
);
process.exit(1);
}
try {
const info = await checkKeycloakConnection(config.keycloak);
process.stdout.write(`✓ Keycloak 连通 · issuer=${info.issuer} · client=${info.clientId}\n`);
} catch (error) {
// 配置错了就别启动,免得跑起来每个请求都 401 还查不出原因(手册第 5 步)
process.stderr.write(`✗ Keycloak 连不上或配置有误:${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}
const container = await createContainer(config);
await container.org.ensureSeeded();
@@ -23,6 +39,9 @@ export async function boot(): Promise<void> {
// 过程在后端:运行中的任务由推进器一拍一拍推,不依赖浏览器在线
const advancer = startTaskAdvancer({ container }, config.dispatchIntervalMs);
// 邮件轮询:腾讯没有带内容的推送,信箱只能拉;跨进程租约让两台后端不重复拉同一个信箱
const mailPoller = startMailPoller({ container }, config.mailPollTickMs);
/*
* 人一答完就踢一脚 —— **顺序有意义**:先把事件投递下去(任务才醒),再推它一拍。
* 反过来登记的话,推进那一轮跑在投递之前,任务还没醒,白跑一轮,
@@ -39,6 +58,7 @@ export async function boot(): Promise<void> {
const shutdown = async (): Promise<void> => {
dispatcher.stop();
advancer.stop();
mailPoller.stop();
await container.close();
process.exit(0);
};
+16 -1
View File
@@ -5,7 +5,7 @@
*
* **清的是「跑出来的」**:任务、工作流、卡、任务记忆、事件、监听、台账行、活动记忆…
* **留的是「配置好的」**:员工与岗位授权、会话、工作组、流程包、活动库、工具、
* 触发器、通道、模型实例、纪要。
* 触发器、通道、模型实例、纪要、**邮箱账号**(它身上的游标与拉取状态是运行痕,跟着清)。
*
* 直接走 SQL 而不绕模块:模块出口里没有、也不该有「删掉所有运行痕迹」这种动作 ——
* 留痕只增不改是系统的规矩,清库是**开发期的外科手术**,不是系统的一项能力。
@@ -44,6 +44,10 @@ const RUN_TABLES = [
'sys.event_delivery',
'sys.event_consumption',
'sys.outbound_mail',
// 邮件收发的留痕是跑出来的;账号配置(sys.mail_account)留着
'sys.mail_inbound',
'sys.mail_inbound_delivery',
'sys.mail_outbound',
'sys.outbound_ref',
'sys.trigger_claim',
// 待确认单是运行痕:命中未确认的那一票随运行数据一起清 —— 配置(trigger/node_owner)不动
@@ -85,6 +89,17 @@ export async function resetRuns(): Promise<void> {
);
const all = [...RUN_TABLES, ...ledger.rows.map((r) => r.name)];
await pool.query(`truncate table ${all.join(', ')} restart identity cascade`);
/*
* 邮件账号是**配置**(留着),但它身上挂着的**游标是运行痕** —— 清了 `sys.mail_inbound`
* 却留着游标,下一轮就从游标处往后拉:wecom 那十分钟重叠窗会把最近的信当新信**再投一次**
* (库里已经没有那一行了,唯一键挡不住),可能凭空再起一单。
* 游标清空 = 下一轮重锚在当下、不回填,与「第一次配上账号」同一个起点。
* 上次拉取 / 错误 / 最近来信也一并清:它们说的都是刚被清掉的那些信。
*/
await pool.query(
`update sys.mail_account
set cursor = null, last_polled_at = null, last_poll_error = null, last_inbound_at = null, polling_until = null`,
);
const kept = await pool.query<{ n: string }>(
`select count(*)::text as n from ledger.__object_meta where kind = 'master'`,
);
+128
View File
@@ -0,0 +1,128 @@
/**
* 路由注册表(ADR-012)—— 「哪条路由归哪个端」的唯一事实源是契约本身:
* 每条 `RouteContract` 自带 `access`,这里把契约命名空间扫一遍,给中间件一个 `lookup`。
* 新路由写在契约里就自动生效,不必谁记得再加一行守卫(漏一条就是一个没人管的口子)。
*
* 匹配:`:name` 段认任意非空段;字面精确命中优先于参数段命中;方法不同不混。
*/
import type { HttpMethod, RouteAccess, RouteContract } from '@contracts';
import { HTTP_METHODS } from '@contracts';
export type RegisteredRoute = {
readonly method: HttpMethod;
readonly path: string;
readonly access: RouteAccess;
};
export type RouteRegistry = {
readonly size: number;
/**
* 按请求的方法与实际路径查归属;查不到回 undefined(中间件按 any 处置,随后 404)。
* **只认 `HTTP_METHODS` 那五个方法,归一是调用方的职责** —— 比如 HEAD:框架把它
* 改写成 GET 再分发,这里照字面查必然 miss,中间件必须先归一再来查。
*/
lookup(method: string, path: string): RouteAccess | undefined;
/** 按注册时的路径模版查(覆盖测试用):`has('GET', '/api/gov/tasks/:taskId/log')` */
has(method: string, pathPattern: string): boolean;
};
const METHODS: readonly string[] = HTTP_METHODS;
export function isRouteContract(value: unknown): value is RouteContract {
if (typeof value !== 'object' || value === null) return false;
const v = value as { method?: unknown; path?: unknown; access?: unknown; output?: unknown };
return (
typeof v.method === 'string' &&
METHODS.includes(v.method) &&
typeof v.path === 'string' &&
v.access !== undefined &&
v.output !== undefined
);
}
/** 从一堆导出里挑出路由契约(`Object.values(contracts)` 喂进来) */
export function routesOf(values: readonly unknown[]): RegisteredRoute[] {
return values.filter(isRouteContract).map((r) => ({ method: r.method, path: r.path, access: r.access }));
}
function escapeSegment(segment: string): string {
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function matcherOf(pattern: string): RegExp {
const body = pattern
.split('/')
.map((seg) => (seg.startsWith(':') ? '[^/]+' : escapeSegment(seg)))
.join('/');
return new RegExp(`^${body}$`);
}
/**
* 两条模版会不会匹配到同一个具体路径:同段数,且每一位都兼容
* (参数段与任何段兼容;字面段只与相同的字面兼容)。
*/
function canCollide(a: string, b: string): boolean {
const sa = a.split('/');
const sb = b.split('/');
if (sa.length !== sb.length) return false;
return sa.every((seg, i) => {
const other = sb[i] ?? '';
if (seg.startsWith(':') || other.startsWith(':')) return true;
return seg === other;
});
}
export function buildRouteRegistry(routes: readonly RegisteredRoute[]): RouteRegistry {
const literal = new Map<string, RouteAccess>();
const patterns: Array<{ method: string; pattern: string; re: RegExp; access: RouteAccess }> = [];
for (const r of routes) {
if (r.path.includes(':')) {
patterns.push({ method: r.method, pattern: r.path, re: matcherOf(r.path), access: r.access });
continue;
}
/*
* 同方法同路径注册两遍:后写的静默盖掉先写的,命中谁全看 `Object.values(contracts)`
* 的顺序 —— 与两条模版相撞是同一个病、同一个后果(静默判错权限)。
* 注册表是唯一的授权事实源,它的自检不该有半边缺口:字面这一半照样掀桌子。
*/
const key = `${r.method} ${r.path}`;
if (literal.has(key)) {
throw new Error(
`路由归属表冲突:${key} 注册了两遍 —— 后写的会静默盖掉先写的,等于静默判错权限。删掉重复的那一条。`,
);
}
literal.set(key, r.access);
}
/*
* 两条模版能匹配到同一个具体路径 = 查它时命中谁全看声明顺序 —— 那是**静默判错权限**,
* 比启动失败严重得多。契约是我们自己写的,撞了就是写错了:开发期就掀桌子。
* 只检模版之间;字面与参数并存是**有意的**优先级(字面赢),不算冲突。
*/
for (let i = 0; i < patterns.length; i += 1) {
for (let j = i + 1; j < patterns.length; j += 1) {
const a = patterns[i];
const b = patterns[j];
if (a === undefined || b === undefined) continue;
if (a.method === b.method && canCollide(a.pattern, b.pattern)) {
throw new Error(
`路由归属表冲突:${a.method} ${a.pattern} 与 ${a.method} ${b.pattern} 会匹配到同一个具体路径 —— ` +
'命中谁只看声明顺序,等于静默判错权限。改掉其中一条的路径。',
);
}
}
}
return {
// 实际可查询的条数(重复被拒之后它与 routes.length 相等 —— 但语义上它说的是表里有几条)
size: literal.size + patterns.length,
lookup(method, path) {
const exact = literal.get(`${method} ${path}`);
if (exact !== undefined) return exact;
return patterns.find((p) => p.method === method && p.re.test(path))?.access;
},
has(method, pathPattern) {
return literal.has(`${method} ${pathPattern}`) || patterns.some((p) => p.method === method && p.pattern === pathPattern);
},
};
}
+3 -2
View File
@@ -9,6 +9,7 @@ import type { Hono } from 'hono';
import type { ApiError, ApprovalItem, ProcessPackage, ProcessNode } from '@contracts';
import { decideApprovalRoute, listApprovalsForMeRoute } from '@contracts';
import type { Container } from '../container.ts';
import { staffOf } from '../http-env.ts';
import type { AppEnv } from '../http-env.ts';
/** 驳回走向的人读说明 —— 声明式,写在流程包里,不由临场决定 */
@@ -23,7 +24,7 @@ export function registerDeskApprovalRoutes(app: Hono<AppEnv>, container: Contain
/** 我岗位上待批的审批点 —— 审批人由节点声明的岗位解析而来 */
app.get(listApprovalsForMeRoute.path, async (c) => {
const me = await container.org.findByAccount(c.get('account'));
const me = staffOf(c);
if (me === undefined) return c.json(unauthenticated, 401);
const workflows = await container.process.listWorkflows(50);
@@ -72,7 +73,7 @@ export function registerDeskApprovalRoutes(app: Hono<AppEnv>, container: Contain
});
app.post(decideApprovalRoute.path, async (c) => {
const me = await container.org.findByAccount(c.get('account'));
const me = staffOf(c);
if (me === undefined) return c.json(unauthenticated, 401);
const parsed = decideApprovalRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
+7 -10
View File
@@ -23,13 +23,10 @@ import { MinuteNotFoundError, ResolutionAlreadyRegisteredError } from '@modules/
import type { Report } from '@modules/report';
import { ReportAlreadySubmittedError, ReportNotFoundError } from '@modules/report';
import type { Container } from '../container.ts';
import { staffOf } from '../http-env.ts';
import type { AppEnv } from '../http-env.ts';
export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Container): void {
async function me(account: string) {
return container.org.findByAccount(account);
}
const unauthenticated: ApiError = { code: 'unauthenticated', message: '会话无效' };
/** 汇报投影:草案内容平铺,前端不必知道 draft 的嵌套 */
@@ -85,7 +82,7 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
});
app.post(createMinuteRoute.path, async (c) => {
const staff = await me(c.get('account'));
const staff = staffOf(c);
if (staff === undefined) return c.json(unauthenticated, 401);
const parsed = createMinuteRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
@@ -104,7 +101,7 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
});
app.post(appendResolutionRoute.path, async (c) => {
const staff = await me(c.get('account'));
const staff = staffOf(c);
if (staff === undefined) return c.json(unauthenticated, 401);
const parsed = appendResolutionRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
@@ -129,7 +126,7 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
/** 决议 → 登记起单:登记人即来源(OS 3.1) */
app.post(registerResolutionRoute.path, async (c) => {
const staff = await me(c.get('account'));
const staff = staffOf(c);
if (staff === undefined) return c.json(unauthenticated, 401);
const parsed = registerResolutionRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
@@ -167,14 +164,14 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
/* ── 汇报 ─────────────────────────────────────────────────────────── */
app.get(listReportsRoute.path, async (c) => {
const staff = await me(c.get('account'));
const staff = staffOf(c);
if (staff === undefined) return c.json(unauthenticated, 401);
const reports = await container.report.list(staff.id);
return c.json({ reports: reports.map(toReportView) });
});
app.get(draftReportRoute.path, async (c) => {
const staff = await me(c.get('account'));
const staff = staffOf(c);
if (staff === undefined) return c.json(unauthenticated, 401);
const period = reportPeriodSchema.safeParse(c.req.param('period'));
if (!period.success) {
@@ -186,7 +183,7 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
});
app.post(submitReportRoute.path, async (c) => {
const staff = await me(c.get('account'));
const staff = staffOf(c);
if (staff === undefined) return c.json(unauthenticated, 401);
const parsed = submitReportRoute.body.safeParse(await c.req.json().catch(() => ({})));
try {
+26 -37
View File
@@ -28,7 +28,7 @@ import {
} from '@contracts';
import { ReplyRejectedError } from '@modules/human-tool';
import { advanceOrder } from '@modules/process';
import { keyText, listArchived, readArchived } from '../asset-archive.ts';
import { assetRowsOf, keyText, listArchived, readArchived } from '../asset-archive.ts';
import { composeFile, isFileFormat, MIME_OF, safeFileName, type FileFormat } from '../file-compose.ts';
import { toFileDoc } from '../file-doc.ts';
import { displayLog } from './log-lines.ts';
@@ -41,6 +41,7 @@ function rowsOf<T>(result: unknown): T[] {
const rows = (result as { rows?: unknown }).rows;
return Array.isArray(rows) ? (rows as T[]) : [];
}
import { staffOf } from '../http-env.ts';
import type { AppEnv } from '../http-env.ts';
import { coerceToColumn, missingReadings, rowLabel } from '../wiring/engines.ts';
import { orderChat } from '../chat-order.ts';
@@ -510,6 +511,9 @@ async function landingOf(
* 两个来源,**存档优先**:放行之后那一份已经冻住(归档件点开的必须是他签字那一刻
* 的那一份);在办期间还没有存档,就照台账现列 —— 人在审的那几份也该看得见。
* 认行一律 `address + key`,与件上 `data-file` 那一串同一形状:点开走同一条路。
*
* 在办这一路挑哪几行与存档同一段(`assetRowsOf`):认**本单碰过的行**,
* 不是这张表里现有的所有行 —— 两处各写一份的下场见那段的病根记档。
*/
async function assetFilesOf(
container: Container,
@@ -529,12 +533,11 @@ async function assetFilesOf(
}
const declarations = await declarationsOf(container, taskId);
// 本单碰过的行 —— 「这一单产出的」只有这一个答案(整表里还躺着别单放行过的资产)
const touched = await container.ledger.taskDrafts(taskId).catch(() => []);
const out: Array<{ address: string; key: string; title: string; format: string; name: string; archived: boolean }> = [];
for (const declared of declarations ?? []) {
const spec = declared.file;
if (spec === undefined) continue;
const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name);
if (keys.length === 0) continue;
if (declared.file === undefined) continue;
const page = await container.ledger
.taskView(taskId, {
address: { domain: declared.domain, object: declared.object },
@@ -544,21 +547,13 @@ async function assetFilesOf(
offset: 0,
})
.catch(() => ({ rows: [] as Array<Record<string, unknown>> }));
for (const row of page.rows) {
// 没正文的行不是资料 —— 摆出来点开只会落一句「还没产出」
if (String(row[spec.bodyColumn] ?? '').trim() === '') continue;
const key: Record<string, string> = {};
for (const k of keys) key[k] = String(row[k] ?? '');
if (Object.values(key).some((v) => v === '')) continue;
const rawFormat = String(row[spec.formatColumn] ?? '').trim().toLowerCase();
const format = isFileFormat(rawFormat) ? rawFormat : 'md';
const title = String(row[spec.titleColumn] ?? '').trim() || Object.values(key).join('-');
for (const asset of assetRowsOf(declared, page.rows as Array<Record<string, unknown>>, touched)) {
out.push({
address: `${declared.domain}.${declared.object}`,
key: keyText(key),
title,
format,
name: safeFileName(title, format),
address: asset.address,
key: keyText(asset.key),
title: asset.title,
format: asset.format,
name: safeFileName(asset.title, asset.format),
archived: false,
});
}
@@ -852,12 +847,6 @@ export function gateOf(
}
export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): void {
/** 当前登录者;缺会话即 401 */
async function me(account: string) {
const staff = await container.org.findByAccount(account);
return staff;
}
/** 任务卡 —— 一条 = 一个活动的一次运行;序位取自工作流的节点实例 */
async function toCard(task: Awaited<ReturnType<Container['task']['get']>>): Promise<TaskCard> {
let position = 1;
@@ -955,7 +944,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
}
app.get(listTasksRoute.path, async (c) => {
const staff = await me(c.get('account'));
const staff = staffOf(c);
if (staff === undefined) {
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
return c.json(body, 401);
@@ -966,7 +955,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
});
app.get(taskDetailRoute.path, async (c) => {
const staff = await me(c.get('account'));
const staff = staffOf(c);
if (staff === undefined) {
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
return c.json(body, 401);
@@ -1198,7 +1187,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
* 否则再读回来成 `FileDoc` 给前端渲 —— 读得回来才证明那份文件是真的。
*/
app.get(openAssetFileRoute.path, async (c) => {
const staff = await me(c.get('account'));
const staff = staffOf(c);
if (staff === undefined) {
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
return c.json(body, 401);
@@ -1304,7 +1293,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
});
app.post(saveTableRoute.path, async (c) => {
const staff = await me(c.get('account'));
const staff = staffOf(c);
if (staff === undefined) {
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
return c.json(body, 401);
@@ -1343,7 +1332,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
/** 回传:与后端回传校验**同 schema** —— 前端提交校验用的也是它 */
app.post(replyWorkOrderRoute.path, async (c) => {
const staff = await me(c.get('account'));
const staff = staffOf(c);
if (staff === undefined) {
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
return c.json(body, 401);
@@ -1437,7 +1426,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
* 与工单回传同一条规矩:校验用**同 schema**,拒收由 human-tool 判定并给出违规明细。
*/
app.post(replyChatCardRoute.path, async (c) => {
const staff = await me(c.get('account'));
const staff = staffOf(c);
if (staff === undefined) {
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
return c.json(body, 401);
@@ -1562,7 +1551,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
* 只许改**自己负责的那一单**的记录 —— 别人的单轮不到你替他标已读。
*/
app.post(markCommReadRoute.path, async (c) => {
const staff = await me(c.get('account'));
const staff = staffOf(c);
if (staff === undefined) {
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
return c.json(body, 401);
@@ -1621,7 +1610,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
});
app.post(sayRoute.path, async (c) => {
const staff = await me(c.get('account'));
const staff = staffOf(c);
if (staff === undefined) {
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
return c.json(body, 401);
@@ -1636,7 +1625,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
});
app.get(listMessagesRoute.path, async (c) => {
const staff = await me(c.get('account'));
const staff = staffOf(c);
if (staff === undefined) {
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
return c.json(body, 401);
@@ -1692,7 +1681,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
/** 确认起单(触发确认链路第 5 步)—— 幂等;只有被点名的确认人按得动 */
app.post(confirmTriggerPendingRoute.path, async (c) => {
const staff = await me(c.get('account'));
const staff = staffOf(c);
if (staff === undefined) {
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
return c.json(body, 401);
@@ -1716,7 +1705,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
});
app.post(markMessageReadRoute.path, async (c) => {
const staff = await me(c.get('account'));
const staff = staffOf(c);
if (staff === undefined) {
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
return c.json(body, 401);
@@ -1731,7 +1720,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
* 由 message 经统一入口写回来源任务;调用与回传全程在任务侧留痕。
*/
app.post(answerMessageRoute.path, async (c) => {
const staff = await me(c.get('account'));
const staff = staffOf(c);
if (staff === undefined) {
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
return c.json(body, 401);
+11 -164
View File
@@ -5,9 +5,9 @@
* 「治理动作本身留痕入事件流 —— 治理也被治理。」
*/
import { sql } from 'drizzle-orm';
import { extractText } from '../upload-extract.ts';
import { readUpload, storeUpload, UPLOAD_CAP } from '../upload-store.ts';
import type { Hono } from 'hono';
import type { ApiError, GovernanceFunction, GrantAction } from '@contracts';
import type { ApiError } from '@contracts';
import {
browseLedgerRoute,
controlTaskRoute,
@@ -71,6 +71,7 @@ import {
VersionTakenError,
} from '@modules/process';
import type { Container } from '../container.ts';
import { staffOf } from '../http-env.ts';
import type { AppEnv } from '../http-env.ts';
import { displayLog } from './log-lines.ts';
@@ -366,46 +367,9 @@ async function assembleFromMail(
}
export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Container): void {
/**
* 岗位授权:客体 = 治理功能,动作按路由分级 —— **看是 `read`,动才是 `govern`**。
*
* 客体三类 × 动作三种的分法就是为此:BD 有 `task:read` 能看在途,却动不了控制事件;
* PM 有 `task:govern`,读写皆通(动作蕴含 govern ⊃ write ⊃ read)。
* 一律要 govern 会把只读监控也锁死,等于把三种动作压成一种。
*/
function guard(fn: GovernanceFunction, action: GrantAction) {
return async (account: string): Promise<ApiError | undefined> => {
const staff = await container.org.findByAccount(account);
if (staff === undefined) return { code: 'unauthenticated', message: '会话无效' };
const ok = await container.org.authorize(staff.id, { kind: 'governance', function: fn }, action);
if (ok) return undefined;
const need = action === 'govern' ? '治理权' : '查看权';
return { code: 'forbidden', message: `岗位 ${staff.position.code} 无「${fn}」${need}` };
};
}
const readTask = guard('task', 'read');
const governTask = guard('task', 'govern');
const readProcess = guard('process', 'read');
const governProcess = guard('process', 'govern');
const readTrigger = guard('trigger', 'read');
const governTrigger = guard('trigger', 'govern');
const readActivity = guard('activity', 'read');
const governActivity = guard('activity', 'govern');
const readTool = guard('tool', 'read');
const governTool = guard('tool', 'govern');
const readData = guard('data', 'read');
const readMemory = guard('memory', 'read');
/** 拒绝的 HTTP 语义:无权 403,无会话 401 */
const denyStatus = (body: ApiError): 401 | 403 => (body.code === 'forbidden' ? 403 : 401);
/* ── 任务管理:在途监控 · 控制 · 改派 · 任务日志 ──────────────────── */
app.get(listGovTasksRoute.path, async (c) => {
const denied = await readTask(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const { staff } = await container.org.listStaff();
const nameOf = new Map(staff.map((s) => [s.id, s.name]));
const rows = await Promise.all(
@@ -438,9 +402,7 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
/** 控制三语义 —— 叫停留痕为最重档纠正信号 */
app.post(controlTaskRoute.path, async (c) => {
const denied = await governTask(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const staff = await container.org.findByAccount(c.get('account'));
const staff = staffOf(c);
const parsed = controlTaskRoute.body.safeParse(await c.req.json());
if (!parsed.success || staff === undefined) {
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
@@ -456,18 +418,14 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
});
app.post(resumeTaskRoute.path, async (c) => {
const denied = await governTask(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const staff = await container.org.findByAccount(c.get('account'));
const staff = staffOf(c);
if (staff === undefined) return c.json({ code: 'unauthenticated', message: '会话无效' } satisfies ApiError, 401);
const task = await container.task.resume(taskIdSchema.parse(c.req.param('taskId')), staff.id);
return c.json({ ok: true, runtime: task.runtime });
});
app.post(reassignTaskRoute.path, async (c) => {
const denied = await governTask(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const staff = await container.org.findByAccount(c.get('account'));
const staff = staffOf(c);
const parsed = reassignTaskRoute.body.safeParse(await c.req.json());
if (!parsed.success || staff === undefined) {
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
@@ -482,8 +440,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
});
app.get(govTaskLogRoute.path, async (c) => {
const denied = await readTask(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const lines = await container.memory.taskLog(taskIdSchema.parse(c.req.param('taskId')));
return c.json({
// 与工作台同一份日志、同一套口径:章是四字动作,内容带主语
@@ -502,9 +458,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
* 它在实现上是卡到审批人工位的验收工单,`taskId` 位坐的是 workflowId(见 wiring/engines.ts)。
*/
app.get(listApprovalsRoute.path, async (c) => {
const denied = await readTask(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const workflows = await container.process.listWorkflows(50);
const perWorkflow = await Promise.all(
workflows.map(async (w) => {
@@ -551,14 +504,12 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
* (指派不是代答,人那一环还是真的),测试台只管起单与看穿全程。
*/
app.post(testRunRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = testRunRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues };
return c.json(body, 400);
}
const me = await container.org.findByAccount(c.get('account'));
const me = staffOf(c);
const { packageId, mail, assignments } = parsed.data;
try {
const data = await assembleFromMail(container, packageId, mail);
@@ -592,8 +543,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
* 仿真不是生产:试跑留下的任务不该在别人工作台上过夜,也不该混进在途口径。
*/
app.post(testStopRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = testStopRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '停止仿真入参不合协议' };
@@ -620,8 +569,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
/** 当前在跑的仿真 —— 刷新页面也认得回来(测试台一次只跑一条) */
app.get(testCurrentRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
/*
* **在跑与跑完要分得开**(`live`)。
*
@@ -668,14 +615,12 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
* `origin='testbed'`,好让人随时连根拔掉。
*/
app.post(testRunActivityRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = testRunActivityRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues };
return c.json(body, 400);
}
const me = await container.org.findByAccount(c.get('account'));
const me = staffOf(c);
const { activityId, data, message, ownerId } = parsed.data;
/*
* 派给谁:显式指定优先,否则**派给操作者自己**。
@@ -729,8 +674,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
* 拿 trace 树看不出「第三拍为什么被拒」—— 那句话在任务轨迹里。
*/
app.get(testActivityStateRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const taskId = taskIdSchema.parse(c.req.param('taskId'));
/*
* **「这一单没了」是 404,不是 500**(2026-08-23 用户:「推进完成了,但测试台还在
@@ -776,16 +719,9 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
});
});
/* ── 上传件:附件要能真正进活动 ─────────────────────────────────── */
/** 上限 8MB —— 超了当场拒,不静默截断(截断过的文件比没有更害人) */
const UPLOAD_CAP = 8 * 1024 * 1024;
app.post(uploadFileRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = uploadFileRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '上传入参不合协议', detail: parsed.error.issues };
@@ -805,47 +741,22 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
return c.json(body, 400);
}
// 读文本就在入口这一步做完:活动那一侧读得懂的只有文字,读不出就如实记 note
const got = extractText(name, contentType, bytes);
const me = await container.org.findByAccount(c.get('account'));
const ref = crypto.randomUUID();
await container.db.execute(sql`
insert into sys.upload (id, name, content_type, size, bytes, text, note, uploaded_by)
values (${ref}, ${name}, ${contentType}, ${bytes.length}, ${Buffer.from(bytes)},
${got.text ?? null}, ${got.note}, ${me?.id ?? null})`);
return c.json({
ref,
name,
contentType,
size: bytes.length,
...(got.text === undefined ? {} : { text: got.text }),
note: got.note,
});
return c.json(await storeUpload(container.db, { name, contentType, bytes, uploadedBy: staffOf(c)?.id ?? null }));
});
app.get(downloadFileRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const rows = await container.db.execute<{ name: string; content_type: string; bytes: Buffer }>(
sql`select name, content_type, bytes from sys.upload where id = ${c.req.param('id')}`,
);
const hit = rowsOf<{ name: string; content_type: string; bytes: Buffer }>(rows)[0];
const hit = await readUpload(container.db, c.req.param('id'));
if (hit === undefined) {
const body: ApiError = { code: 'not-found', message: '这个文件不在了' };
return c.json(body, 404);
}
return c.json({
name: hit.name,
contentType: hit.content_type,
dataBase64: Buffer.from(hit.bytes).toString('base64'),
});
return c.json({ name: hit.name, contentType: hit.contentType, dataBase64: Buffer.from(hit.bytes).toString('base64') });
});
/* ── 试跑日志:历次试跑的存档,按实例管理 ────────────────────────── */
/** 列表只给读数与计数 —— 一次试跑的轨迹上百条,列表页不需要正文 */
app.get(listTestLogsRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const rows = await container.db.execute<TestLogDbRow>(sql`
select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps,
jsonb_array_length(trail) as trail_count,
@@ -857,8 +768,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
});
app.get(testLogDetailRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const rows = await container.db.execute<TestLogDbRow & { trail: unknown; drafts: unknown; cards: unknown }>(sql`
select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps,
jsonb_array_length(trail) as trail_count,
@@ -875,8 +784,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
});
app.delete(deleteTestLogRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const rows = await container.db.execute<{ id: string }>(
sql`delete from sys.testbed_run where id = ${c.req.param('id')} returning id`,
);
@@ -884,25 +791,18 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
});
app.delete(clearTestLogsRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const rows = await container.db.execute<{ id: string }>(sql`delete from sys.testbed_run returning id`);
return c.json({ deleted: rowsOf<{ id: string }>(rows).length });
});
/** 全链 Trace:一条工作流的 span 树 —— 节点 → 步骤 / 工具 / 模型 / 人卡 */
app.get(traceOfWorkflowRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const spans = await container.trace.ofWorkflow(c.req.param('id'));
return c.json({ spans });
});
/** 工作流下钻:流程包 × 节点实例 × 在途任务 —— 编排画布的数据面 */
app.get(govWorkflowRoute.path, async (c) => {
const denied = await readTask(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const workflow = await container.process.getWorkflow(c.req.param('id'));
const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion);
if (pkg === undefined) {
@@ -945,8 +845,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
/* ── 工作流管理 ──────────────────────────────────────────────────── */
app.get(listProcessPackagesRoute.path, async (c) => {
const denied = await readProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
return c.json({ packages: await container.process.listPackages() });
});
@@ -955,8 +853,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
* 编辑即其身:保存写回当前版本;在途钉版不可变,新版本只出自显式新建。
*/
app.put(saveProcessRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = saveProcessRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues };
@@ -986,8 +882,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
/** 版本启停:激活即独占,停用即下线 —— 在途按启动版本跑完,零影响 */
/** 删一版:生效版先停用(409),在途钉版不许删(409)—— 那些单要按它跑完 */
app.delete(deleteProcessVersionRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
try {
const packages = await container.process.deleteVersion(c.req.param('id'), Number(c.req.param('version')));
return c.json({ packages });
@@ -1004,14 +898,10 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
/* ── 审批模板(审批活动)—— 活动库里配、编排里引 ─────────────── */
app.get(listApprovalTemplatesRoute.path, async (c) => {
const denied = await readProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
return c.json({ templates: await container.process.approvalTemplates() });
});
app.put(upsertApprovalTemplateRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = upsertApprovalTemplateRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '审批模板不合协议(标识与名称必填)' };
@@ -1021,8 +911,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
});
app.delete(deleteApprovalTemplateRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
try {
await container.process.deleteApprovalTemplate(c.req.param('id'));
return c.json({ ok: true });
@@ -1042,8 +930,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
* 反查放在路由层而不是用例层:process 不该知道 trigger 的存在(L7 不依赖它)。
*/
app.delete(deleteProcessFlowRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const id = c.req.param('id');
const rules = (await container.trigger.list()).filter((t) => t.processPackageId === id);
if (rules.length > 0) {
@@ -1067,8 +953,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
/** 改版本记录:号自己定、名随之 —— 撞号与「跑过单改号」409,别的版一字不动 */
app.post(editProcessVersionRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = editProcessVersionRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '版本号要 1–9999 的整数,名字 1–40 字' };
@@ -1096,8 +980,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
});
app.post(setProcessStateRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = setProcessStateRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '启停状态不合协议' };
@@ -1122,8 +1004,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
/** 画图即校验 —— 与前端画布同源 */
app.post(validateProcessRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = validateProcessRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues };
@@ -1135,14 +1015,10 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
/* ── 触发器配置 ──────────────────────────────────────────────────── */
app.get(listTriggersRoute.path, async (c) => {
const denied = await readTrigger(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
return c.json({ triggers: await container.trigger.list() });
});
app.post(toggleTriggerRoute.path, async (c) => {
const denied = await governTrigger(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = toggleTriggerRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
@@ -1158,8 +1034,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
* 设计动规则不碰这两项,运营动这两项不碰规则。
*/
app.post(setTriggerConfirmRoute.path, async (c) => {
const denied = await governTrigger(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = setTriggerConfirmRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
@@ -1178,15 +1052,11 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
/** 节点负责人(运营配置):显式指派 > 这里 > 轮值 */
app.get(listNodeOwnersRoute.path, async (c) => {
const denied = await readProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const owners = await container.process.nodeOwners(c.req.param('id'));
return c.json({ owners: Object.entries(owners).map(([nodeId, staffId]) => ({ nodeId, staffId })) });
});
app.put(setNodeOwnerRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = setNodeOwnerRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
@@ -1201,8 +1071,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
* 启停不走这里(那是运营的事,见 toggle);改定义同样不动任何流程包版本。
*/
app.put(upsertTriggerRoute.path, async (c) => {
const denied = await governTrigger(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = upsertTriggerRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '触发器不合 schema', detail: parsed.error.issues };
@@ -1219,8 +1087,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
* 恒为新增、版本并存:装入不覆盖旧版本,在途任务按启动时版本跑完。
*/
app.post(installActivityRoute.path, async (c) => {
const denied = await governActivity(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = installActivityRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '活动清单不合 schema', detail: parsed.error.issues };
@@ -1259,8 +1125,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
});
app.get(listActivitiesRoute.path, async (c) => {
const denied = await readActivity(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const cards = await container.registry.list();
/*
* 契约面随卡一起给 —— 开发一个活动要照着的东西全在清单里:
@@ -1374,8 +1238,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
* 入目录不等于能用:还要治理台把它放进某个活动的白名单(授权在白名单与契约,不在活动代码)。
*/
app.post(registerToolRoute.path, async (c) => {
const denied = await governTool(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = registerToolRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '工具契约不合 schema', detail: parsed.error.issues };
@@ -1397,8 +1259,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
});
app.get(listToolsRoute.path, async (c) => {
const denied = await readTool(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const tools = await container.toolbox.list();
return c.json({
tools: tools.map((t) => ({
@@ -1417,8 +1277,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
/* ── 数据管理:域 / 对象目录 + 记录浏览 ─────────────────────────── */
app.get(listLedgerObjectsRoute.path, async (c) => {
const denied = await readData(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
/*
* 性质随 `listObjects` **一次查询带回**(0026_object_meta)——
* 这里从前是拿 12 个活动的清单现场重建的:`manifestOf` 是 select *,
@@ -1438,9 +1296,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
* · 草稿行数与时刻 —— `ledger.__draft`(在办中的单写下的,放行才进正式表)。
*/
app.get(ledgerByActivityRoute.path, async (c) => {
const denied = await readData(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const [cards, materialized] = await Promise.all([
container.registry.list(),
container.ledger.listObjects(),
@@ -1557,8 +1412,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
});
app.get(browseLedgerRoute.path, async (c) => {
const denied = await readData(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const address = { domain: c.req.param('domain'), object: c.req.param('object') };
const described = await container.ledger.describe(address);
if (described === undefined) {
@@ -1595,8 +1448,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
* 仪表读数,同源同口径」),这里只是把它接上,不另起一套口径。
*/
app.get(govMetricsRoute.path, async (c) => {
const denied = await readMemory(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = govMetricsRoute.query.safeParse(c.req.query());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '统计窗口不合法', detail: parsed.error.issues };
@@ -1612,8 +1463,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
/* ── 系统管理 · 通道实例 ────────────────────────────────────────── */
app.get(listChannelsRoute.path, async (c) => {
const denied = await readTrigger(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const channels = await container.channel.list();
return c.json({
channels: channels.map((ch) => ({
@@ -1632,8 +1481,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
});
app.get(listActivityMemoryRoute.path, async (c) => {
const denied = await readMemory(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const entries = await container.memory.loadActivityMemory(c.req.param('activityId'), { limit: 100 });
return c.json({
entries: entries.map((e) => ({
+91 -96
View File
@@ -1,107 +1,122 @@
/**
* 治理台路由组装(编码规范 §3:治理台九功能不设后端 governance 模块 ——
* 每个功能是对应模块的管理用例 + 本文件的路由组装)。
* 端级授权由 server.ts 的鉴权中间件按路由归属统一判(ADR-012),这里不再各写守卫。
*/
import type { Hono } from 'hono';
import type { ApiError, GovernanceFunction } from '@contracts';
import type { ApiError } from '@contracts';
import {
createStaffRoute,
disableStaffCheckRoute,
disableStaffRoute,
listGrantsRoute,
deleteLlmInstanceRoute,
deleteRoleAccessRoute,
listLlmInstancesRoute,
listLlmProvidersRoute,
listRoleAccessRoute,
listStaffRoute,
listWorkGroupsRoute,
positionIdSchema,
listLlmProvidersRoute,
LLM_PROVIDERS,
deleteLlmInstanceRoute,
probeLlmDraftRoute,
probeLlmInstanceRoute,
saveWorkGroupRoute,
setGrantsRoute,
setRoleAccessRoute,
setStaffPositionRoute,
staffIdSchema,
staffInFlightRoute,
syncStaffRoute,
upsertLlmInstanceRoute,
} from '@contracts';
import { DisableBlockedError } from '@modules/org';
import { AccessLockoutError, PositionNotFoundError, StaffNotFoundError } from '@modules/org';
import { PROVIDER_PROFILES } from '@modules/llm';
import type { Container } from '../container.ts';
import type { AppEnv } from '../http-env.ts';
/** 治理动作要过岗位授权(客体 = 治理功能,动作 = 治理) */
function requireGovern(container: Container, fn: GovernanceFunction) {
return async (account: string): Promise<ApiError | undefined> => {
const staff = await container.org.findByAccount(account);
if (staff === undefined) return { code: 'unauthenticated', message: '会话无效' };
const ok = await container.org.authorize(staff.id, { kind: 'governance', function: fn }, 'govern');
return ok ? undefined : { code: 'forbidden', message: `岗位 ${staff.position.code} 无「${fn}」治理权` };
};
}
export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container): void {
const guardStaff = requireGovern(container, 'staff');
const guardSystem = requireGovern(container, 'system');
/* ── 员工管理:名册来自鉴权中心,这里只管岗位与同步(ADR-012) ─────────── */
/* ── 员工管理 ─────────────────────────────────────────────────────── */
app.get(listStaffRoute.path, async (c) => c.json(await container.org.listStaff()));
app.get(listStaffRoute.path, async (c) => {
const denied = await guardStaff(c.get('account'));
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
return c.json(await container.org.listStaff());
});
app.post(createStaffRoute.path, async (c) => {
const denied = await guardStaff(c.get('account'));
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
const parsed = createStaffRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
return c.json(body, 400);
}
return c.json(await container.org.createStaff(parsed.data));
});
app.get(disableStaffCheckRoute.path, async (c) => {
const denied = await guardStaff(c.get('account'));
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
const staffId = staffIdSchema.parse(c.req.param('staffId'));
return c.json(await container.org.checkDisable(staffId));
});
app.post(disableStaffRoute.path, async (c) => {
const denied = await guardStaff(c.get('account'));
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
const staffId = staffIdSchema.parse(c.req.param('staffId'));
/**
* 从 Keycloak Admin API 同步名册;没配 provisioning 就回 configured:false(界面不撒谎)。
* 409:这一次同步会把最后一个能进运营中心的人也停了 —— 整体拒绝,一条都没写
* (与「角色 → 端」那两条同款,理由不同、话不同)。
*/
app.post(syncStaffRoute.path, async (c) => {
try {
await container.org.disableStaff(staffId);
return c.json({ ok: true, check: await container.org.checkDisable(staffId) });
return c.json(await container.org.syncRoster());
} catch (error) {
if (error instanceof DisableBlockedError) {
// 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02)
const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.check };
return c.json(body, 412);
if (error instanceof AccessLockoutError) {
const body: ApiError = { code: 'conflict', message: error.message };
return c.json(body, 409);
}
throw error;
}
});
app.get(listGrantsRoute.path, async (c) => {
const denied = await guardStaff(c.get('account'));
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
const positionId = positionIdSchema.parse(c.req.param('positionId'));
return c.json({ grants: await container.org.listGrants(positionId) });
});
app.post(setGrantsRoute.path, async (c) => {
const denied = await guardStaff(c.get('account'));
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
const positionId = positionIdSchema.parse(c.req.param('positionId'));
const parsed = setGrantsRoute.body.safeParse(await c.req.json());
app.put(setStaffPositionRoute.path, async (c) => {
const staffId = staffIdSchema.parse(c.req.param('staffId'));
const parsed = setStaffPositionRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
return c.json(body, 400);
}
return c.json({ grants: await container.org.setGrants(positionId, parsed.data.grants) });
try {
return c.json(await container.org.setPosition(staffId, parsed.data.positionId));
} catch (error) {
if (error instanceof StaffNotFoundError || error instanceof PositionNotFoundError) {
const body: ApiError = { code: 'not-found', message: error.message };
return c.json(body, 404);
}
throw error;
}
});
/** 此人身上还挂着什么 —— 原「停用前置检查」降为只读信息;停用在 Keycloak 做 */
app.get(staffInFlightRoute.path, async (c) => {
const staffId = staffIdSchema.parse(c.req.param('staffId'));
try {
return c.json(await container.org.inFlightOf(staffId));
} catch (error) {
if (error instanceof StaffNotFoundError) {
const body: ApiError = { code: 'not-found', message: error.message };
return c.json(body, 404);
}
throw error;
}
});
/* ── 角色 → 端(ADR-012) ─────────────────────────────────────────────── */
app.get(listRoleAccessRoute.path, async (c) => c.json(await container.org.listRoleAccess()));
/** 锁死不变量不过(改完没人能进运营中心)→ 409 */
app.put(setRoleAccessRoute.path, async (c) => {
const role = c.req.param('role');
const parsed = setRoleAccessRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
return c.json(body, 400);
}
try {
return c.json(await container.org.setRoleAccess(role, parsed.data.ends));
} catch (error) {
if (error instanceof AccessLockoutError) {
const body: ApiError = { code: 'conflict', message: error.message };
return c.json(body, 409);
}
throw error;
}
});
app.delete(deleteRoleAccessRoute.path, async (c) => {
try {
await container.org.deleteRoleAccess(c.req.param('role'));
return c.json({ ok: true });
} catch (error) {
if (error instanceof AccessLockoutError) {
const body: ApiError = { code: 'conflict', message: error.message };
return c.json(body, 409);
}
throw error;
}
});
/* ── 员工管理 · 工作组(ADR-011) ──────────────────────────────────── */
@@ -111,8 +126,6 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
* `ownerName` 一并带出:前端要显示的是「柴浩的组」,不是一串 id。
*/
app.get(listWorkGroupsRoute.path, async (c) => {
const denied = await guardStaff(c.get('account'));
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
const { staff } = await container.org.listStaff();
const found = await Promise.all(
staff.map(async (s) => {
@@ -129,8 +142,6 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
* 改组**不影响在途**:装进工作流的是起单时的快照。
*/
app.put(saveWorkGroupRoute.path, async (c) => {
const denied = await guardStaff(c.get('account'));
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
const parsed = saveWorkGroupRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '工作组不合 schema', detail: parsed.error.issues };
@@ -149,10 +160,8 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
/* ── 系统管理 · LLM 实例 ──────────────────────────────────────────── */
/** Provider 档案:默认端点与出参口径 —— 建实例的表单据它预填(唯一真相在服务端) */
app.get(listLlmProvidersRoute.path, async (c) => {
const denied = await guardSystem(c.get('account'));
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
return c.json({
app.get(listLlmProvidersRoute.path, (c) =>
c.json({
providers: LLM_PROVIDERS.map((provider) => {
const profile = PROVIDER_PROFILES[provider];
return {
@@ -162,18 +171,12 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
structuredOutput: profile.structuredOutput,
};
}),
});
});
}),
);
app.get(listLlmInstancesRoute.path, async (c) => {
const denied = await guardSystem(c.get('account'));
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
return c.json({ instances: await container.llm.list() });
});
app.get(listLlmInstancesRoute.path, async (c) => c.json({ instances: await container.llm.list() }));
app.post(upsertLlmInstanceRoute.path, async (c) => {
const denied = await guardSystem(c.get('account'));
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
const parsed = upsertLlmInstanceRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
@@ -184,8 +187,6 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
/** 删实例:被活动声明着的用途名不许删(409)—— 反查放这一层,llm 不该知道活动的存在 */
app.delete(deleteLlmInstanceRoute.path, async (c) => {
const denied = await guardSystem(c.get('account'));
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
const id = c.req.param('id');
const target = (await container.llm.list()).find((i) => i.id === id);
if (target === undefined) {
@@ -208,8 +209,6 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
/** 测试连接(草稿):只测不存 —— 必须排在 `/:id/probe` 之前,否则会被那条路由吃掉 */
app.post(probeLlmDraftRoute.path, async (c) => {
const denied = await guardSystem(c.get('account'));
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
const parsed = probeLlmDraftRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
@@ -218,9 +217,5 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
return c.json(await container.llm.probeDraft(parsed.data));
});
app.post(probeLlmInstanceRoute.path, async (c) => {
const denied = await guardSystem(c.get('account'));
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
return c.json(await container.llm.probe(c.req.param('id')));
});
app.post(probeLlmInstanceRoute.path, async (c) => c.json(await container.llm.probe(c.req.param('id'))));
}
+191
View File
@@ -0,0 +1,191 @@
/**
* 邮件收发路由(设计稿 §7.2;ADR-013)。归属在契约的 access 上,中间件统一判;
* 这里只做 zod 校验、调用用例、把领域错误翻成状态码。
* 探测 / 试发 / 拉取的**厂商失败在 200 的 body 里如实说** —— 不是接口错误。
*/
import type { Context, Hono } from 'hono';
import type { ApiError } from '@contracts';
import {
createMailAccountRoute,
deleteMailAccountRoute,
getMailAccountRoute,
getMailInboundRoute,
listMailAccountsRoute,
listMailInboundRoute,
listMailOutboundRoute,
mailAttachmentRoute,
mailRawRoute,
pollMailAccountRoute,
probeMailAccountRoute,
probeMailDraftRoute,
redeliverMailInboundRoute,
sendTestMailRoute,
updateMailAccountRoute,
} from '@contracts';
import type { MailService } from '@modules/mail';
import {
MailAccountDisabledError,
MailAccountNameTakenError,
MailAccountNotFoundError,
MailAttachmentNotFoundError,
MailInboundNotFoundError,
MailNoDefaultAccountError,
MailPollBusyError,
MailSecretMissingError,
MailSendInvalidError,
MailSubscriberUnknownError,
} from '@modules/mail';
import type { AppEnv } from '../http-env.ts';
import { staffOf } from '../http-env.ts';
export type MailRouteDeps = { readonly mail: MailService };
/** 领域错误 → 状态码;认不出的交回 onError(500) */
export function mailErrorResponse(error: unknown): { status: 400 | 404 | 409 | 412; body: ApiError } | undefined {
if (
error instanceof MailAccountNotFoundError ||
error instanceof MailInboundNotFoundError ||
error instanceof MailAttachmentNotFoundError ||
error instanceof MailSubscriberUnknownError
) {
return { status: 404, body: { code: 'not-found', message: error.message } };
}
if (error instanceof MailAccountNameTakenError || error instanceof MailPollBusyError) {
return { status: 409, body: { code: 'conflict', message: error.message } };
}
if (error instanceof MailAccountDisabledError || error instanceof MailNoDefaultAccountError || error instanceof MailSecretMissingError) {
return { status: 412, body: { code: 'precondition-failed', message: error.message } };
}
if (error instanceof MailSendInvalidError) {
return { status: 400, body: { code: 'validation-failed', message: error.message, detail: error.problems } };
}
return undefined;
}
function invalid(c: Context<AppEnv>, message: string, detail: unknown): Response {
const body: ApiError = { code: 'validation-failed', message, detail };
return c.json(body, 400);
}
async function jsonBody(c: Context<AppEnv>): Promise<unknown> {
const text = await c.req.text();
if (text.trim() === '') return {};
try {
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
}
export function registerMailRoutes(app: Hono<AppEnv>, deps: MailRouteDeps): void {
const { mail } = deps;
const guarded = async (c: Context<AppEnv>, run: () => Promise<Response>): Promise<Response> => {
try {
return await run();
} catch (error) {
const mapped = mailErrorResponse(error);
if (mapped === undefined) throw error;
return c.json(mapped.body, mapped.status);
}
};
/* ── 账号 ── */
app.get(listMailAccountsRoute.path, async (c) => c.json({ accounts: await mail.listAccounts() }));
app.post(createMailAccountRoute.path, async (c) => {
const parsed = createMailAccountRoute.body.safeParse(await jsonBody(c));
if (!parsed.success) return invalid(c, '账号入参不合法', parsed.error.issues);
return guarded(c, async () => c.json(await mail.createAccount(parsed.data)));
});
app.post(probeMailDraftRoute.path, async (c) => {
const parsed = probeMailDraftRoute.body.safeParse(await jsonBody(c));
if (!parsed.success) return invalid(c, '账号入参不合法', parsed.error.issues);
const accountId = c.req.query('accountId');
return guarded(c, async () =>
c.json(await mail.probe({ input: parsed.data, ...(accountId === undefined || accountId === '' ? {} : { accountId }) })),
);
});
app.get(getMailAccountRoute.path, async (c) => guarded(c, async () => c.json(await mail.getAccount(c.req.param('accountId')))));
app.put(updateMailAccountRoute.path, async (c) => {
const parsed = updateMailAccountRoute.body.safeParse(await jsonBody(c));
if (!parsed.success) return invalid(c, '账号入参不合法', parsed.error.issues);
return guarded(c, async () => c.json(await mail.updateAccount(c.req.param('accountId'), parsed.data)));
});
app.delete(deleteMailAccountRoute.path, async (c) =>
guarded(c, async () => {
await mail.deleteAccount(c.req.param('accountId'));
return c.json({ ok: true as const });
}),
);
app.post(probeMailAccountRoute.path, async (c) => guarded(c, async () => c.json(await mail.probe({ accountId: c.req.param('accountId') }))));
app.post(pollMailAccountRoute.path, async (c) => guarded(c, async () => c.json(await mail.pollNow(c.req.param('accountId')))));
app.post(sendTestMailRoute.path, async (c) => {
const parsed = sendTestMailRoute.body.safeParse(await jsonBody(c));
if (!parsed.success) return invalid(c, '试发入参不合法', parsed.error.issues);
const me = staffOf(c);
return guarded(c, async () =>
c.json(
await mail.send({
accountId: c.req.param('accountId'),
to: [parsed.data.to],
cc: [],
bcc: [],
subject: parsed.data.subject,
text: parsed.data.text,
references: [],
attachments: [],
requestedBy: { kind: 'ops', ...(me === undefined ? {} : { staffId: me.id }) },
}),
),
);
});
/* ── 入站 ── */
app.get(listMailInboundRoute.path, async (c) => {
const query = listMailInboundRoute.query.safeParse(c.req.query());
if (!query.success) return invalid(c, '查询参数不合法', query.error.issues);
return c.json({ mails: await mail.listInbound({ accountId: query.data.accountId, limit: query.data.limit }) });
});
app.get(getMailInboundRoute.path, async (c) => guarded(c, async () => c.json(await mail.getInbound(c.req.param('mailId')))));
app.get(mailAttachmentRoute.path, async (c) => {
const params = mailAttachmentRoute.params.safeParse({ mailId: c.req.param('mailId'), index: c.req.param('index') });
if (!params.success) return invalid(c, '附件序号不合法', params.error.issues);
return guarded(c, async () => {
const file = await mail.attachment(params.data.mailId, params.data.index);
return c.json({ name: file.name, contentType: file.contentType, dataBase64: Buffer.from(file.bytes).toString('base64') });
});
});
app.get(mailRawRoute.path, async (c) =>
guarded(c, async () => {
const file = await mail.raw(c.req.param('mailId'));
return c.json({ name: file.name, contentType: 'message/rfc822' as const, dataBase64: Buffer.from(file.bytes).toString('base64') });
}),
);
app.post(redeliverMailInboundRoute.path, async (c) => {
const parsed = redeliverMailInboundRoute.body.safeParse(await jsonBody(c));
if (!parsed.success) return invalid(c, '入参不合法', parsed.error.issues);
return guarded(c, async () => c.json({ deliveries: await mail.redeliver(c.req.param('mailId'), parsed.data.subscriber) }));
});
/* ── 出站 ── */
app.get(listMailOutboundRoute.path, async (c) => {
const query = listMailOutboundRoute.query.safeParse(c.req.query());
if (!query.success) return invalid(c, '查询参数不合法', query.error.issues);
return c.json({ mails: await mail.listOutbound({ accountId: query.data.accountId, limit: query.data.limit }) });
});
}
+3 -2
View File
@@ -10,6 +10,7 @@ import type { Hono } from 'hono';
import type { ApiError } from '@contracts';
import { fireRegistrationRoute, listRegistrationTriggersRoute, triggerKindOf } from '@contracts';
import type { Container } from '../container.ts';
import { staffOf } from '../http-env.ts';
import type { AppEnv } from '../http-env.ts';
export function registerRegistrationRoutes(app: Hono<AppEnv>, container: Container): void {
@@ -17,7 +18,7 @@ export function registerRegistrationRoutes(app: Hono<AppEnv>, container: Contain
/** 可登记起单的触发器 + 各自流程包的调用接口 schema(表单由它自动渲染) */
app.get(listRegistrationTriggersRoute.path, async (c) => {
const me = await container.org.findByAccount(c.get('account'));
const me = staffOf(c);
if (me === undefined) return c.json(unauthenticated, 401);
const triggers = await container.trigger.list();
@@ -38,7 +39,7 @@ export function registerRegistrationRoutes(app: Hono<AppEnv>, container: Contain
});
app.post(fireRegistrationRoute.path, async (c) => {
const me = await container.org.findByAccount(c.get('account'));
const me = staffOf(c);
if (me === undefined) return c.json(unauthenticated, 401);
const parsed = fireRegistrationRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
+16 -40
View File
@@ -1,13 +1,13 @@
/**
* 工作台路由组装 —— 路由契约在 @contracts/api,实现在此(编码规范 §3 / §9.2)。
* 登录 / 登出属鉴权面:中间件在 app 层,身份判定读 `org` 出口(§4 / ADR-010)。
* 登录已归统一鉴权中心(ADR-012):这里只剩「我是谁」与票据。
*/
import type { Hono } from 'hono';
import type { ApiError } from '@contracts';
import { CONTRACTS_VERSION, healthRoute, loginRoute, logoutRoute, sessionRoute } from '@contracts';
import { CONTRACTS_VERSION, authTicketRoute, healthRoute, meRoute } from '@contracts';
import type { Container } from '../container.ts';
import { SESSION_COOKIE, clearSessionCookie, setSessionCookie } from '../session-cookie.ts';
import type { AppEnv } from '../http-env.ts';
import { systemClock } from '../runtime.ts';
export function registerWorkbenchRoutes(app: Hono<AppEnv>, container: Container): void {
app.get(healthRoute.path, async (c) => {
@@ -21,44 +21,20 @@ export function registerWorkbenchRoutes(app: Hono<AppEnv>, container: Container)
return c.json({ ok: db === 'up', version: CONTRACTS_VERSION, db });
});
/** 我是谁 —— 会话由中间件解好放进上下文,这里只回投影 */
app.get(sessionRoute.path, (c) => {
const session = c.get('session');
if (session === undefined) {
const body: ApiError = { code: 'unauthenticated', message: '尚未登录' };
return c.json(body, 401);
}
return c.json(session);
const unauthenticated: ApiError = { code: 'unauthenticated', message: '缺少 Bearer token' };
/** 我是谁、我能进哪几个端 —— 中间件已把 principal 放进上下文,这里只回投影;ends 为空也 200 */
app.get(meRoute.path, (c) => {
const principal = c.get('principal');
if (principal === undefined) return c.json(unauthenticated, 401);
return c.json(principal);
});
app.post(loginRoute.path, async (c) => {
const parsed = loginRoute.body.safeParse(await c.req.json().catch(() => ({})));
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '请填写账号与口令' };
return c.json(body, 400);
}
const issued = await container.org.login(parsed.data.account, parsed.data.password);
// 失败只有一句话:不区分账号不存在 / 已停用 / 口令错,不外泄账号是否存在
if (issued === undefined) {
const body: ApiError = { code: 'unauthenticated', message: '账号或口令不对' };
return c.json(body, 401);
}
const session = await container.org.resolveSession(issued.token);
if (session === undefined) {
const body: ApiError = { code: 'internal', message: '会话签发后立即失效' };
return c.json(body, 500);
}
setSessionCookie(c, issued.token, new Date(issued.expiresAt));
return c.json(session);
});
app.post(logoutRoute.path, async (c) => {
const token = c.req.header('cookie')?.match(new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`))?.[1];
// 服务端表先作废,再清 cookie —— 顺序反了会留下「cookie 没了但会话还活着」的窗口
if (token !== undefined) await container.org.logout(token);
clearSessionCookie(c);
return c.json({ ok: true });
/** 60 秒票据(ADR-012 §9.3)—— 给 EventSource 与 <a href> 下载这两条带不了 Authorization 头的路 */
app.get(authTicketRoute.path, (c) => {
const principal = c.get('principal');
if (principal === undefined) return c.json(unauthenticated, 401);
const issued = container.tickets.issue(principal.staff.id, systemClock.now());
return c.json({ ticket: issued.ticket, expiresAt: issued.expiresAt.toISOString() });
});
}
+52 -9
View File
@@ -48,21 +48,60 @@ const HISTORY_TOOL = {
connectionKeys: ['endpoint'],
};
/** 出站邮件 —— **写类**:过程中登记为动作意向,放行后由系统执行落地 */
const MAIL_TOOL = {
/**
* 出站邮件 —— **写类**:过程中登记为动作意向,放行后由系统执行落地;真发经 mail 模块(ADR-013)。
*
* 2.0.0(2026-08-25):`to` 改数组,加 cc / bcc / html / attachments(uploadRef)/ inReplyTo / accountId / dryRun。
* 契约演进只增不删:v1 的 `body`(纯文本正文)与出参 `queued` 保留。
* `connectionKeys: ['account']` —— 从哪个信箱发:'default' = 默认账号,或某个账号 id。
* `dryRun`:注册的试运行会**真调处理器**,样例带 dryRun 只校验不发。
*/
export const MAIL_TOOL = {
...HISTORY_TOOL,
name: 'mail.send',
displayName: '发送邮件',
description: '发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界',
version: '2.0.0',
description:
'从系统配置的邮箱发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界。' +
'to 是收件人数组;正文给 body(纯文本)或 html,至少一个;附件给上传件的 uploadRef;回信带 inReplyTo(来信的 Message-ID)。',
input: {
type: 'object',
required: ['to', 'subject'],
properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } },
properties: {
to: { type: 'array', items: { type: 'string' }, description: '收件人邮箱' },
cc: { type: 'array', items: { type: 'string' }, description: '抄送' },
bcc: { type: 'array', items: { type: 'string' }, description: '密送' },
subject: { type: 'string' },
body: { type: 'string', description: '纯文本正文(与 html 至少一个)' },
html: { type: 'string', description: 'HTML 正文' },
attachments: {
type: 'array',
items: { type: 'object', required: ['uploadRef'], properties: { uploadRef: { type: 'string' }, name: { type: 'string' } } },
description: '附件:上传件的 ref',
},
inReplyTo: { type: 'string', description: '回信时带上来信的 Message-ID,收件人那边才能串成一个线程' },
accountId: { type: 'string', description: '指定发件账号;缺省用连接配置里的账号 / 默认账号' },
dryRun: { type: 'boolean', description: '只校验不发(注册试运行用)' },
},
},
output: {
type: 'object',
properties: {
queued: { type: 'boolean', description: 'v1 遗留:真发时恒 true' },
outboundId: { type: 'string' },
messageId: { type: 'string' },
state: { type: 'string' },
dryRun: { type: 'boolean' },
},
},
output: { type: 'object', properties: { queued: { type: 'boolean' } } },
effect: 'write' as const,
errors: [
{ code: 'send-failed', retryable: true, message: '发信失败(厂商原话见 message)' },
{ code: 'no-account', retryable: false, message: '没有可用的邮箱账号' },
{ code: 'invalid', retryable: false, message: '入参不合法' },
],
idempotent: true,
connectionKeys: ['endpoint'],
connectionKeys: ['account'],
};
/**
@@ -297,8 +336,10 @@ export async function seedMvp(container: Container): Promise<void> {
// 样例集一份喂四件工具:并集覆盖各家必填
input: {
pn: 'LM358DR',
to: 'x@y.com',
to: ['x@y.com'],
subject: '询价',
body: '样例正文',
dryRun: true,
history_days: 30,
market_stable: true,
lifecycle: '在产',
@@ -306,8 +347,10 @@ export async function seedMvp(container: Container): Promise<void> {
expectShape: { type: 'object' },
},
],
// 占位工具是纯工具,没有连接目标 —— 与真工具同门过两审,但不给它端点
...(contract.connectionKeys.length === 0 ? {} : { connectionValues: { endpoint: 'replay://crm' } }),
// 占位工具是纯工具,没有连接目标 —— 与真工具同门过两审,但不给它端点;mail.send 的连接目标是「从哪个信箱发」
...(contract.connectionKeys.length === 0
? {}
: { connectionValues: Object.fromEntries(contract.connectionKeys.map((k) => [k, k === 'account' ? 'default' : 'replay://crm'])) }),
});
if (!verdict.accepted) {
log(' 工具 ' + contract.name + ' 未过两审:' + verdict.staticFindings.map((f) => f.message).join('; '));
+2 -2
View File
@@ -11,8 +11,8 @@ export async function seed(): Promise<void> {
try {
await container.org.ensureSeeded();
const { staff, positions } = await container.org.listStaff();
process.stdout.write(`种子完成:${positions.length} 个岗位 / ${staff.length} 名员工\n`);
for (const s of staff) process.stdout.write(` ${s.account.padEnd(12)} ${s.name}(${s.position.code})\n`);
process.stdout.write(`种子完成:${positions.length} 个岗位;名册 ${staff.length} 人(人来自鉴权中心:首登自动建档 / 运营中心同步)\n`);
for (const s of staff) process.stdout.write(` ${s.account.padEnd(24)} ${s.name}(${s.position.code})\n`);
// MVP 底座:四域数据 · 六活动槽位 · 1:1 流程包(README MVP 完成判据第 1 条)
process.stdout.write('\n── 治理台配置 ──\n');
+53 -31
View File
@@ -1,13 +1,26 @@
/**
* Hono 应用 —— 自建应用,Mastra 以库形态嵌入(ADR-001)。
* 鉴权、岗位权限、SSE 全归 app 层;不暴露 Mastra 默认端点。
* 鉴权、端归属、SSE 全归 app 层;不暴露 Mastra 默认端点。
*/
import { Hono } from 'hono';
import type { ApiError } from '@contracts';
import { healthRoute, ingestMailRoute, loginRoute } from '@contracts';
import * as contracts from '@contracts';
import type { ApiError, RouteContract } from '@contracts';
import {
createStaffRoute,
disableStaffCheckRoute,
disableStaffRoute,
listGrantsRoute,
loginRoute,
logoutRoute,
sessionRoute,
setGrantsRoute,
} from '@contracts';
import type { Container } from './container.ts';
import type { AppEnv } from './http-env.ts';
import { readSessionToken } from './session-cookie.ts';
import { createAuthMiddleware } from './auth-middleware.ts';
import { buildRouteRegistry, routesOf } from './route-registry.ts';
import type { RegisteredRoute, RouteRegistry } from './route-registry.ts';
import { systemClock } from './runtime.ts';
import { registerDeskRoutes } from './routes/desk.ts';
import { registerDeskElementRoutes } from './routes/desk-elements.ts';
import { registerDeskApprovalRoutes } from './routes/desk-approval.ts';
@@ -15,46 +28,47 @@ import { registerRegistrationRoutes } from './routes/registration.ts';
import { registerGovernanceOpsRoutes } from './routes/governance-ops.ts';
import { registerGovernanceRoutes } from './routes/governance.ts';
import { registerIngestRoutes } from './routes/ingest.ts';
import { registerMailRoutes } from './routes/mail.ts';
import { registerStreamRoute } from './routes/stream.ts';
import { registerWorkbenchRoutes } from './routes/workbench.ts';
export type { AppEnv };
/**
* 免鉴权白名单(ADR-010)—— 只三条,其余一律 401。
* 入站邮件是**外界边界**:它代替真实 IMAP 拉件,不是人的请求,没有会话可言。
*/
const INGEST_PREFIX = ingestMailRoute.path.slice(0, ingestMailRoute.path.indexOf(':'));
/** 字面路径注册、不在契约里的路由 —— 注册表得知道它们归哪儿(SSE 只要登录) */
export const EXTRA_ROUTES: readonly RegisteredRoute[] = [{ method: 'GET', path: '/api/stream', access: 'any' }];
function isPublic(method: string, path: string): boolean {
if (method === 'GET' && path === healthRoute.path) return true;
if (method === 'POST' && path === loginRoute.path) return true;
if (method === 'POST' && path.startsWith(INGEST_PREFIX)) return true;
return false;
/** 全部路由的归属(ADR-012)—— 契约命名空间扫一遍 + 字面路径补入;新路由写在契约里就自动生效 */
export function appRouteRegistry(): RouteRegistry {
return buildRouteRegistry([...routesOf(Object.values(contracts)), ...EXTRA_ROUTES]);
}
/**
* 随统一鉴权中心退役的路由(§12.4 三步之第二步):契约留一轮给 web 编译,后端一律 410 并指路。
* 它们的 access 是 public —— 410 不需要凭据,也不泄漏任何东西。
*/
const RETIRED_ROUTES: ReadonlyArray<{ readonly route: RouteContract; readonly hint: string }> = [
{ route: loginRoute, hint: '登录由 Keycloak 完成,请求带 Authorization: Bearer <token>' },
{ route: logoutRoute, hint: '登出在前端丢弃 token,或调 Keycloak 的 end-session' },
{ route: sessionRoute, hint: '改用 GET /api/me' },
{ route: createStaffRoute, hint: '人来自鉴权中心 —— 首次登录自动建档,或 POST /api/gov/staff/sync 同步名册' },
{ route: disableStaffCheckRoute, hint: '改用 GET /api/gov/staff/:staffId/in-flight' },
{ route: disableStaffRoute, hint: '在 Keycloak 停用账号后 POST /api/gov/staff/sync' },
{ route: listGrantsRoute, hint: '岗位授权已退役,改用 GET /api/gov/access/roles(角色→端)' },
{ route: setGrantsRoute, hint: '岗位授权已退役,改用 PUT /api/gov/access/roles/:role' },
];
export function createServer(container: Container): Hono<AppEnv> {
const app = new Hono<AppEnv>();
/**
* 鉴权中间件(编码规范 §4:中间件在 app 层,身份判定读 `org` 出口;ADR-010)。
*
* 身份**只能**来自服务端会话表:cookie 里是一枚不透明 token,改它只会让会话解不出来,
* 冒充不了任何人 —— 这正是明文 `jcai_account` cookie 被废止的理由。
* 鉴权中间件(编码规范 §4:中间件在 app 层,身份判定读 `org` 出口;ADR-012)。
* 凭据只认验过签的 Bearer token(或由它换来的 60 秒票据);归属按路由注册表统一判,
* 路由里不再各写守卫 —— 一处拦住,新路由自动生效。
*/
app.use('*', async (c, next) => {
const token = readSessionToken(c.req.header('cookie'));
const session = token === undefined ? undefined : await container.org.resolveSession(token);
c.set('session', session);
c.set('account', session?.staff.account ?? '');
if (session === undefined && !isPublic(c.req.method, c.req.path)) {
const body: ApiError = { code: 'unauthenticated', message: '尚未登录' };
return c.json(body, 401);
}
await next();
return undefined;
});
app.use(
'*',
createAuthMiddleware({ registry: appRouteRegistry(), org: container.org, tickets: container.tickets, clock: systemClock }),
);
/**
* 治理面的写操作 → **一律广播一次 `system`**(2026-07-30)。
@@ -92,6 +106,13 @@ export function createServer(container: Container): Hono<AppEnv> {
return c.json(body, 404);
});
for (const { route, hint } of RETIRED_ROUTES) {
app.on(route.method, route.path, (c) => {
const body: ApiError = { code: 'gone', message: `已随统一鉴权中心退役:${hint}` };
return c.json(body, 410);
});
}
registerWorkbenchRoutes(app, container);
registerGovernanceRoutes(app, container);
registerDeskRoutes(app, container);
@@ -99,6 +120,7 @@ export function createServer(container: Container): Hono<AppEnv> {
registerDeskApprovalRoutes(app, container);
registerRegistrationRoutes(app, container);
registerGovernanceOpsRoutes(app, container);
registerMailRoutes(app, container);
registerIngestRoutes(app, container);
registerStreamRoute(app, container.broadcaster);
-39
View File
@@ -1,39 +0,0 @@
/**
* 会话 cookie 的读写(ADR-010)—— 只此一处拼 `Set-Cookie`,属性不许各写各的。
*
* `HttpOnly`:脚本读不到 token,XSS 拿不走会话。
* `SameSite=Lax`:跨站表单 POST 不带 cookie,挡掉 CSRF 的常见形态;
* 同站导航仍带,登录后跳转不受影响。
* 开发期走 http://localhost,故 `Secure` 随部署环境开关(生产必开)。
*/
import type { Context } from 'hono';
import type { AppEnv } from './http-env.ts';
export const SESSION_COOKIE = 'jcai_session';
/** 生产环境必带 Secure;开发期 localhost 是 http,带了浏览器就不存 */
function secureAttr(): string {
return process.env['NODE_ENV'] === 'production' ? '; Secure' : '';
}
export function readSessionToken(cookieHeader: string | undefined): string | undefined {
if (cookieHeader === undefined) return undefined;
const match = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`).exec(cookieHeader);
return match?.[1];
}
export function setSessionCookie(c: Context<AppEnv>, token: string, expiresAt: Date): void {
c.header(
'Set-Cookie',
`${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax${secureAttr()}; Expires=${expiresAt.toUTCString()}`,
{ append: true },
);
}
export function clearSessionCookie(c: Context<AppEnv>): void {
c.header(
'Set-Cookie',
`${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax${secureAttr()}; Max-Age=0`,
{ append: true },
);
}
-31
View File
@@ -1,31 +0,0 @@
/**
* 改密 CLI(ADR-010)—— 治理台的改密界面还没做,先给运维一条路。
*
* pnpm --filter @app/api exec node --experimental-strip-types src/app/set-password.ts <账号> <新口令>
*
* 口令从**命令行参数**取,不写进任何文件;落库前即散列,明文只在进程内存里停留一瞬。
*/
import { loadConfig } from './config.ts';
import { createContainer } from './container.ts';
export async function setPassword(account: string, password: string): Promise<void> {
const container = await createContainer(loadConfig());
try {
const staff = await container.org.findByAccount(account);
if (staff === undefined) throw new Error(`账号不存在:${account}`);
await container.org.setPassword(staff.id, password);
process.stdout.write(`已改密:${staff.name}(${account})\n`);
} finally {
await container.close();
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
const [account, password] = process.argv.slice(2);
if (account === undefined || password === undefined) {
process.stderr.write('用法:set-password.ts <账号> <新口令>\n');
process.exit(1);
}
await setPassword(account, password);
process.exit(0);
}
+43
View File
@@ -0,0 +1,43 @@
/**
* 60 秒票据(ADR-012 §9.3)—— 只为两条带不了 Authorization 头的路:
* `EventSource('/api/stream')` 与 `<a href="…&raw=1" download>`。
*
* 形状 `v1.<sub-b64url>.<expUnix>.<hmac-b64url>`;密钥由 JCAI_SECRET_KEY 经 HKDF 派生(不直接拿主密钥签)。
* 票据里**没有权限**:中间件拿 sub 回库现算 principal,停用 / 改角色即时生效。
* 无状态、不单次 —— 60 秒可重放窗换零状态表(记 debt);只在 GET 的 ?ticket= 上认,POST 带了也当没带。
*/
import { createHmac, hkdfSync, timingSafeEqual } from 'node:crypto';
export const TICKET_TTL_MS = 60_000;
export type TicketIssuer = {
issue(staffId: string, now: Date): { ticket: string; expiresAt: Date };
verify(ticket: string, now: Date): { staffId: string } | undefined;
};
export function createTicketIssuer(masterKey: Buffer): TicketIssuer {
const key = Buffer.from(hkdfSync('sha256', masterKey, '', 'jcai-ticket', 32));
const sign = (body: string): string => createHmac('sha256', key).update(body).digest('base64url');
return {
issue(staffId, now) {
const expUnix = Math.floor((now.getTime() + TICKET_TTL_MS) / 1000);
const body = `v1.${Buffer.from(staffId, 'utf8').toString('base64url')}.${expUnix}`;
return { ticket: `${body}.${sign(body)}`, expiresAt: new Date(expUnix * 1000) };
},
verify(ticket, now) {
const parts = ticket.split('.');
if (parts.length !== 4) return undefined;
const [version, idRaw, expRaw, mac] = parts;
if (version !== 'v1' || idRaw === undefined || idRaw === '' || expRaw === undefined || mac === undefined) return undefined;
const expected = sign(`${version}.${idRaw}.${expRaw}`);
const given = Buffer.from(mac, 'utf8');
const wanted = Buffer.from(expected, 'utf8');
if (given.length !== wanted.length || !timingSafeEqual(given, wanted)) return undefined;
const expUnix = Number(expRaw);
if (!Number.isInteger(expUnix) || expUnix * 1000 <= now.getTime()) return undefined;
const staffId = Buffer.from(idRaw, 'base64url').toString('utf8');
return staffId === '' ? undefined : { staffId };
},
};
}
-10
View File
@@ -286,15 +286,6 @@ const rankKeyPersons: ToolHandler = async ({ input }) => {
return { ok: true, output: { ranked, counts } };
};
/** 出站发送 —— 写类:过程中登记为动作意向,放行后由系统执行落地(OS 7.3) */
const mailSend: ToolHandler = async ({ input, connection }) => {
// 运行时只拿得到申报过的连接目标;真发信由 channel 的出站面承担
return {
ok: true,
output: { queued: true, to: String(input['to'] ?? ''), via: connection['endpoint'] ?? 'simulated' },
};
};
/* ── 活动槽位的占位产出(编排层跑通期专用) ─────────────────────────────
*
* 六个活动都只有契约面、没有实现。而出口载荷只从**落地事实**推导,
@@ -349,7 +340,6 @@ export const FIRST_PARTY_TOOLS: ReadonlyArray<readonly [string, ToolHandler]> =
['quote-pricing.floor-gate', floorGate],
['quote-archive.profile', profile],
['quote-archive.landing-rate', landingRate],
['mail.send', mailSend],
['slot.placeholder', slotPlaceholder],
['crm-relationship-coverage.recompute-score', recomputeScore],
['crm-relationship-coverage.rank-key-persons', rankKeyPersons],
+42
View File
@@ -0,0 +1,42 @@
/**
* sys.upload 的存取 —— 上传路由与 mail 的附件注入件同用这一段(设计稿 §8)。
* 表是 0024 手写的 SQL,不在 Drizzle 里;照旧走 sql``。
*/
import { sql } from 'drizzle-orm';
import type { Db } from '@shared/db.ts';
import { extractText } from './upload-extract.ts';
/** 上限 8MB —— 超了当场拒,不静默截断(截断过的文件比没有更害人) */
export const UPLOAD_CAP = 8 * 1024 * 1024;
function rowsOf<T>(result: unknown): T[] {
const bag = result as { rows?: T[] } | T[];
return Array.isArray(bag) ? bag : (bag.rows ?? []);
}
export type StoredUpload = { ref: string; name: string; contentType: string; size: number; text?: string; note: string };
/** 存字节 + 读出来的文本;`note` 给了就拼在抽取说明前面(邮件附件写「邮件附件:主题」) */
export async function storeUpload(
db: Db,
file: { name: string; contentType: string; bytes: Uint8Array; uploadedBy: string | null; note?: string | undefined },
): Promise<StoredUpload> {
const got = extractText(file.name, file.contentType, file.bytes);
const ref = crypto.randomUUID();
const note = file.note === undefined || file.note === '' ? got.note : `${file.note};${got.note}`;
await db.execute(sql`
insert into sys.upload (id, name, content_type, size, bytes, text, note, uploaded_by)
values (${ref}, ${file.name}, ${file.contentType}, ${file.bytes.length}, ${Buffer.from(file.bytes)},
${got.text ?? null}, ${note}, ${file.uploadedBy})`);
return { ref, name: file.name, contentType: file.contentType, size: file.bytes.length, ...(got.text === undefined ? {} : { text: got.text }), note };
}
export async function readUpload(db: Db, ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined> {
const rows = await db.execute<{ name: string; content_type: string; bytes: Buffer }>(
sql`select name, content_type, bytes from sys.upload where id = ${ref}`,
);
const hit = rowsOf<{ name: string; content_type: string; bytes: Buffer }>(rows)[0];
if (hit === undefined) return undefined;
// pg 的 Buffer 挂在共享内存池上,按元素拷(2026-08-23 的前科)
return { name: hit.name, contentType: hit.content_type, bytes: Uint8Array.from(hit.bytes) };
}
+19 -5
View File
@@ -17,6 +17,7 @@ import { renderView } from '@modules/interaction';
import type { InteractionService } from '@modules/interaction';
import type { MessageService } from '@modules/message';
import type { OrgService } from '@modules/org';
import { NONE_POSITION_CODE } from '@modules/org';
import type { LlmService, ModelGateway } from '@modules/llm';
import type { ActivityRegistryService } from '@modules/activity-registry';
import type { SkillEngineService } from '@modules/skill-engine';
@@ -2277,6 +2278,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
taskId: input.taskId,
ids,
declarations: manifest.declarations.ledger,
// 「这一单产出的」= 本单碰过的行:整表里躺着历次跑出来的资产,不是这一单的成果
touched: await deps.ledger.taskDrafts(input.taskId),
rowsOfAddress: async (address) =>
(
await deps.ledger.taskView(input.taskId, { address, where: {}, orderBy: [], limit: 500, offset: 0 })
@@ -2451,7 +2454,11 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
},
},
org: {
positions: async () => new Set((await deps.org.listStaff()).positions.map((p) => p.code)),
// 哨兵岗 NONE(未分配)不是流程包能声明的岗位 —— 不进候选集(ADR-012)
positions: async () =>
new Set(
(await deps.org.listStaff()).positions.filter((p) => p.code !== NONE_POSITION_CODE).map((p) => p.code),
),
candidates: async (position) => {
const staff = await deps.org.staffOfPosition(position);
const withLoad = await Promise.all(
@@ -2612,10 +2619,17 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
// 起单失败入死信 —— 落日志 + 送治理台的消息要素,不静默丢弃
report: async (input) => {
writeLog(`[trigger] 起单失败入死信:${input.triggerId} —— ${input.reason}`);
const { staff } = await deps.org.listStaff();
for (const person of staff) {
const governs = await deps.org.authorize(person.id, { kind: 'governance', function: 'trigger' }, 'govern');
if (!governs) continue;
// 收件人 = 能进运营中心的人(触发器的启停归运营;ADR-012 权限只到端)
const recipients = await deps.org.staffWithEnd('ops');
// 一个收件人都没有:这条通知这一次谁也收不到 —— 把「出事了」无声丢掉最糟,
// 所以出声(与 container.ts 的 governanceStaff 同一口径)
if (recipients.length === 0) {
writeLog(
'[trigger] 死信通知这一次没有收件人 —— 没有能进运营中心的活跃员工。' +
'去运营中心的「角色 → 端」把某个 realm 角色配上 ops,或让持该角色的人先登录一次。',
);
}
for (const person of recipients) {
await deps.message.notify({
staffId: person.id,
title: `触发器起单失败:${input.triggerId}`,
+41
View File
@@ -0,0 +1,41 @@
/**
* 邮件轮询工作者 —— 每 tick 挑到期账号各拉一轮(设计稿 §4);跨进程租约在 store 里,
* 两个后端连同一个库时一个信箱同一时刻只有一个在拉。
* 与 startDispatcher / startTaskAdvancer 同形;上一 tick 没跑完(账号多、信多)就跳过这一拍,不叠着跑。
*/
import type { MailPollOutcome } from '@modules/mail';
import type { Container } from '../container.ts';
export type MailPollerDeps = { readonly container: Pick<Container, 'mail'>; readonly now?: () => Date };
export async function pollMailOnce(deps: MailPollerDeps): Promise<MailPollOutcome[]> {
const outcomes = await deps.container.mail.pollDue((deps.now ?? (() => new Date()))());
for (const outcome of outcomes) {
if (outcome.error !== undefined) process.stderr.write(`[mail-poller] ${outcome.accountId}:${outcome.error}\n`);
}
return outcomes;
}
export function startMailPoller(deps: MailPollerDeps, tickMs: number): { stop(): void } {
let stopped = false;
let running = false;
const tick = async (): Promise<void> => {
if (stopped || running) return;
running = true;
try {
await pollMailOnce(deps);
} catch (error) {
process.stderr.write(`[mail-poller] ${error instanceof Error ? error.message : String(error)}\n`);
} finally {
running = false;
}
};
const timer = setInterval(() => void tick(), tickMs);
timer.unref();
return {
stop() {
stopped = true;
clearInterval(timer);
},
};
}
+4 -2
View File
@@ -18,7 +18,7 @@
层级: L2(编码规范 §4)
依赖: @contracts;@shared/db;port:`EventGateway`(app 注入 event)、
`LedgerSystemWriter`(app 注入 ledger)、`MailTransport`(模拟通道)
`LedgerSystemWriter`(app 注入 ledger)、`MailTransport`(app 注入,转 mail 模块)
被依赖: release(出站发送)、app(治理台 · 系统管理 / ingest 路由)
不变量:
@@ -29,7 +29,9 @@
- **无主不写台账**:仅有主入站才归一为系统写;无主入站只入事件流并走消息兜底
- 无主键的映射结果丢弃(无从定位),由调用方按提取失败处置
- 出站即登记关联记录:线程 ID 与往来地址是归属解析①③级的对表来源
- 邮件通道是**外界**:MVP 用模拟通道(出站落库不真发,入站经 ingest 注入);
- 邮件通道是**外界**:真通道由 `mail` 模块提供(ADR-013)—— app 把 `channel.ingestMail` 注册为它的订阅方,
把 `MailTransport` 注入为「转 `mail.send`」(按发件地址找账号,对不上不回落默认);
`createSimulatedMailTransport` 只剩测试 / 演示用途,`/api/ingest` 仍是注入门(e2e 用);
系统内部机制一律真跑,不得 mock
影响半径: index/contract/port 未变 → 对外零影响
+76
View File
@@ -0,0 +1,76 @@
# module: mail
职责: 邮箱账号(配置 + 凭证密封)、收(轮询拉取 → MIME 解析 → 入站留痕 → 分发订阅方)、
发(出站留痕 → provider 发出)、订阅注册表、连通探测;两条接入路(IMAP/SMTP · 企业微信邮件 API)
不负责: 归一 / 归属 / 事件(见 channel、event)、放行意向(见 release)、工具白名单(见 toolbox)、
SSE(app 经 `ChangeNotifier` 端口接到 broadcaster)
公开出口: index.ts
- 类型:`MailService` `InboundHandler` 与 @contracts 的 `MailAccountInput` / `MailAccountView` /
`MailInboundSummary` / `MailInboundDetail` / `MailOutboundView` / `MailSendInput` / `MailSendResult` /
`MailProbeReport` / `MailPollOutcome` / `MailDeliveryView` 转出
- 错误:`MailAccountNotFoundError`(404)`MailAccountNameTakenError`(409)`MailAccountDisabledError`(412)
`MailNoDefaultAccountError`(412)`MailSecretMissingError`(412)`MailPollBusyError`(409)
`MailInboundNotFoundError`(404)`MailAttachmentNotFoundError`(404)`MailSubscriberUnknownError`(404)
`MailSubscriberTakenError` `MailSendInvalidError`(400)
- 端口类型:`MailAccountStore` `InboundStore` `OutboundStore` `AttachmentStore` `MailProvider` `MimeParser`
`SecretBox` `Clock` `IdGenerator` `ChangeNotifier`;记录类型 `MailAccountRecord` `InboundRecord`
`OutboundRecord` `DeliveryRecord` `ParsedMail` `OutboundMessage` `ProviderAccount` `RawMessage` `MailCursor`
- 用例工厂:`createMailService(deps: MailServiceDeps): MailService`
- 纯函数:游标(`imapCursor` `advanceImap` `imapNeedsReanchor` `newUids` `wecomCursor` `wecomWindow`
`cursorSummary` `parseCursor`)、到期(`isDue` `isPolling` `leaseMsFor`)、
入站(`buildInboundRecord` `planAttachments` `bodyTextOf` `stripHtml` `threadIdOf` `summaryOf` `detailOf`)、
脱敏(`redactSecrets` `secretVariants` `errorText`)、发信(`validateSend` `pickAccount` `messageIdFor`)、
账号(`viewOf` `mergeAccount` `secretOf` `cursorReset`)、分发(`canRedeliver` `stateOfOutcome`)
- adapter:`createMailAccountStore` `createInboundStore` `createOutboundStore`(Drizzle)、
`createPostalMimeParser`、`createImapSmtpProvider`、`createWecomProvider`;
Drizzle 表:`mailAccountTable` `mailInboundTable` `mailInboundDeliveryTable` `mailOutboundTable`
层级: L1(编码规范 §4)
依赖: @contracts;@shared/db;npm(只在 adapter):imapflow / nodemailer / postal-mime
被依赖: app(container 装配 / routes / workers / tools)。channel 与 toolbox **不 import** 本模块:
channel 经 app 注册为订阅方并由 app 注入 `MailTransport` 转 `mail.send`;
`mail.send` 工具处理器在 app 里闭包持本服务
不变量(ADR-013):
- **入站先持久后分发**:`sys.mail_inbound` 两把唯一键 `(account_id, message_id)` / `(account_id, provider_ref)`,
重复拉到一律 `inserted:false` 静默跳过、不分发;游标逐封(IMAP)/ 逐批(wecom)推进,失败停在最后一封成功处
- **一封解析不动的信不许卡死整条路**(2026-08-25 终审):落 text/jsonb 的字段一律剥 NUL(`stripNul`);
单封 `storeOne` 抛错时先落**降级骨架**(原件原样存、主题从原始字节扒、正文写明失败原因)——
落成了就推进游标并把「第 N 封(providerRef)解析失败,已降级存档」写进 `last_poll_error`;
骨架也落不下才停在这一封,那时 `last_poll_error` **必须点名是哪一封**。降级行**不自动分发**
(正文是失败原因不是客户的话),人可按「重新投递」
- **线程取根**:`threadIdOf` = `references[0] ?? inReplyTo`(ADR-013 决策 3),全仓唯一口径 ——
app 的 `mail-wiring.toInboundMail` 调它,不另抄一份
- **provider 的 `note` 要上界面**:`pull` 回的 `note`(当前唯一一种:UIDVALIDITY 变化已重锚、期间来信未拉取)
落进 `last_poll_error`;丢了一段信而账号页一片绿是界面撒谎
- **读取分两档**:`list` / `getMeta` **不取 `raw` 原件字节**(一封上限 16MB),`rawAvailable` 由
`raw is not null` 投影;只有「下载原件」那一条路走全列的 `get`
- **账号的配置列与运行列分家**:`save()` 只写配置列;运行列(cursor / last_polled_at / last_poll_error /
last_inbound_at / polling_until)只经 `savePoll` / `claimPolling` / `releasePolling` 改 ——
整行 upsert 会把另一台正持着的租约与刚推进的游标盖回读那一刻的快照。清游标是显式的 `savePoll({cursor:null})`
- **重锚只看收侧**:`cursorReset` 只比 kind 与收侧 settings 键(imap-smtp:`imapHost`/`imapPort`/`imapTls`/
`login`/`folder`;wecom:`corpId`/`apiBase`)—— 改 SMTP 端口不该把来信丢掉
- **分发自动只投一次**:先落 `dispatching` 再调,`done / skipped / failed(原话脱敏)`;不自动重试 ——
channel → event 按 `cause.ref` 不幂等,重投会起两单;「重新投递」是人按的钮,那一刻起是至少一次;
一个订阅方失败不影响其它;订阅是代码级装配,不落库、不回放老信
- **首次不回填**:无游标锚在当下;`backfillFrom` 才回填;IMAP 只读打开文件夹,不改已读标记
- **跨进程租约**:`polling_until` 一条 SQL 判 + 占,过期 max(5min, 3×间隔);抢不到 → `MailPollBusyError`
- **出站先落行**:`sending` → provider → `sent` / `failed(原话)`;带 `idempotencyKey` 且已 `sent` 的不发第二封;
厂商失败**不抛**,以 `state='failed'` 返回(调用方各自翻译)
- **复用旧行重发要先抢**:`OutboundStore.claimForResend` 一条条件更新(`failed`、或停够 5 分钟的 `sending`
才抢得到;抢到即置 `sending` 并把 `created_at` 推到 now)—— 抢到才发,抢不到回「本次未重复发送」。
两个后端连同一个库时,这一句是「同键不发两封」唯一的墙
- **凭证密封**:`secret_cipher` 永不出接口,视图只回 `secretBound`;更新时 secret 缺省 = 不改;
明文只在 usecase 打开、交给 provider 那一段;所有落库的厂商原话先过 `redactSecrets`
- **界面不撒谎**:探测收 / 发分栏各报;wecom 的发信一栏 `checked:false`(拿到 token 不等于能发);
wecom 的 `address` 探到之前是空串
- **两缝**(§7.3):`MailProvider` 不经 ModelGateway / ToolInvoker —— 入站是外界事实、持久化即记录
(回放读 `sys.mail_inbound`,永不重拉);活动侧出站经 `ToolInvoker`(`mail.send` 工具),系统侧留痕
`sys.mail_outbound`;测试替身是 `__tests__/fakes.ts` 里的内存件(**不从 index 导出** —— 外界替身只进测试)
影响半径: 新模块;channel / toolbox / event 的 index / contract / port 未变。
2026-08-25 终审修复轮改了 `port.ts`:`InboundStore` 新增 `getMeta`(list / getMeta 不再取 raw)、
`OutboundStore` 新增 `claimForResend`、`MailAccountStore.savePoll` 的 `cursor` 允许显式 `null`、
`MailProvider.pull` 回参可带 `note` —— 端口的实现方只有本模块的 adapter 与 `__tests__/fakes.ts`
的内存件(两边语义必须一致),app 侧装配未变
@@ -0,0 +1,123 @@
/** 账号记录 ↔ 入参 / 视图:视图不带密文;kind / settings 变了游标清空;secret 缺省保留 */
import { describe, expect, it } from 'vitest';
import { mailAccountInputSchema } from '@contracts';
import type { MailAccountInput } from '@contracts';
import type { MailAccountRecord } from '../internal/domain/records.ts';
import { imapCursor } from '../internal/domain/cursor.ts';
import { cursorReset, mergeAccount, secretOf, viewOf } from '../internal/domain/account-view.ts';
const now = new Date('2026-08-25T10:00:00Z');
const input = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售', address: 'sales@we.com', secret: { password: 'pw-1234' } });
describe('mergeAccount / secretOf', () => {
it('新建:id / 时间 / 密文来自 base,游标空', () => {
const record = mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 'sealed', now });
expect(record.id).toBe('acc-1');
expect(record.address).toBe('sales@we.com');
expect(record.secretCipher).toBe('sealed');
expect(record.cursor).toBeNull();
expect(record.createdAt).toBe(now);
expect(secretOf(input)).toBe(JSON.stringify({ password: 'pw-1234' }));
expect(secretOf(mailAccountInputSchema.parse({ kind: 'imap-smtp', name: 'x', address: 'x@we.com' }))).toBeUndefined();
});
it('wecom 的地址不由人填:新建是空串,更新沿用探到的', () => {
const wecom = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' } });
expect(mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now }).address).toBe('');
const existing = mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now });
const probed: MailAccountRecord = { ...existing, address: 'app@corp.wecom.work' };
expect(mergeAccount(wecom, { id: 'w', existing: probed, secretCipher: null, now }).address).toBe('app@corp.wecom.work');
});
it('**只有收侧的键动了才重锚**:改 smtpPort 游标留着,改 imapHost / folder 才清', () => {
/*
* 从前比对全部 settings 键:人把 465 改成 587(纯发信的事)也会清游标 → IMAP 重锚在当下 →
* 上次游标到重锚之间到达的信**永远不拉**(设计即「不回填」)。改发信的设置不该丢来信。
*/
const existing: MailAccountRecord = {
...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 's', now }),
cursor: imapCursor('7', 9),
};
const changed = (over: Record<string, unknown>): MailAccountInput =>
mailAccountInputSchema.parse({ ...input, settings: { ...input.settings, ...over } });
// 发侧三键:一个都不重锚
expect(cursorReset(existing, changed({ smtpPort: 587 }))).toBe(false);
expect(cursorReset(existing, changed({ smtpHost: 'smtp.other.com' }))).toBe(false);
expect(cursorReset(existing, changed({ smtpTls: false }))).toBe(false);
// 收侧:换信箱 / 换文件夹 / 换登录名 / 换端口都是另一个游标
expect(cursorReset(existing, changed({ imapHost: 'imap.other.com' }))).toBe(true);
expect(cursorReset(existing, changed({ folder: 'Archive' }))).toBe(true);
expect(cursorReset(existing, changed({ imapPort: 143 }))).toBe(true);
expect(cursorReset(existing, changed({ imapTls: false }))).toBe(true);
expect(cursorReset(existing, changed({ login: 'other@we.com' }))).toBe(true);
// 只改 smtpPort 时游标真的留在记录里(不只是判据为假)
expect(mergeAccount(changed({ smtpPort: 587 }), { id: 'acc-1', existing, secretCipher: 's', now }).cursor).toEqual(imapCursor('7', 9));
});
it('**登录名空着时,换 address 就是换信箱** —— 收侧判据认的是 `login ?? address`', () => {
/*
* `imapSettingsOf` 里写的是 `login: settings.login ?? account.address`:login 常常空着,
* 这时候真正决定「登到哪个信箱」的是 address。只比 settings 里的 login,
* 人把 address 换成另一个邮箱时判据为假 —— 旧游标的 UID 说的是原来那个箱子,
* 而 UID 是**按信箱**编号的。反过来,显式填了 login 时 address 只是发信落款,换它不动收信。
*/
const base: MailAccountRecord = {
...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 's', now }),
cursor: imapCursor('7', 9),
};
const withAddress = (address: string): MailAccountInput => mailAccountInputSchema.parse({ ...input, address });
// login 空着:address 就是登录名,换了它要重锚
expect(base.settings['login']).toBeUndefined();
expect(cursorReset(base, withAddress('ops@we.com'))).toBe(true);
expect(cursorReset(base, withAddress('sales@we.com'))).toBe(false);
// 显式填了 login:address 只是落款,换它不重锚(而换 login 照旧重锚)
const pinned = mailAccountInputSchema.parse({ ...input, settings: { ...input.settings, login: 'box@we.com' } });
const withPinnedLogin: MailAccountRecord = { ...mergeAccount(pinned, { id: 'acc-1', existing: base, secretCipher: 's', now }), cursor: imapCursor('7', 9) };
expect(cursorReset(withPinnedLogin, mailAccountInputSchema.parse({ ...pinned, address: 'ops@we.com' }))).toBe(false);
expect(cursorReset(withPinnedLogin, mailAccountInputSchema.parse({ ...pinned, settings: { ...pinned.settings, login: 'other@we.com' } }))).toBe(true);
// 游标真的留下 / 真的被清(不只是判据)
expect(mergeAccount(withAddress('ops@we.com'), { id: 'acc-1', existing: base, secretCipher: 's', now }).cursor).toBeNull();
});
it('wecom 形按 corpId / apiBase 判重锚', () => {
const wecom = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' } });
const existing: MailAccountRecord = {
...mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now }),
cursor: null,
};
expect(cursorReset(existing, wecom)).toBe(false);
expect(cursorReset(existing, mailAccountInputSchema.parse({ ...wecom, settings: { corpId: 'ww2' } }))).toBe(true);
expect(cursorReset(existing, mailAccountInputSchema.parse({ ...wecom, settings: { corpId: 'ww1', apiBase: 'https://qyapi.example.com' } }))).toBe(true);
});
it('更新:settings 没变游标保留;变了(或换 kind)游标与上次错误清空', () => {
const existing: MailAccountRecord = {
...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 's', now }),
cursor: imapCursor('7', 9),
lastPollError: '旧错误',
};
expect(cursorReset(existing, input)).toBe(false);
const same = mergeAccount(input, { id: 'acc-1', existing, secretCipher: 's', now });
expect(same.cursor).toEqual(imapCursor('7', 9));
const changed = mailAccountInputSchema.parse({ ...input, settings: { ...input.settings, folder: 'Archive' } });
expect(cursorReset(existing, changed)).toBe(true);
const reset = mergeAccount(changed, { id: 'acc-1', existing, secretCipher: 's', now });
expect(reset.cursor).toBeNull();
expect(reset.lastPollError).toBeNull();
});
});
describe('viewOf', () => {
it('不带密文只带 secretBound;status 带游标摘要与 polling', () => {
const record: MailAccountRecord = {
...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 'sealed', now }),
cursor: imapCursor('7', 9),
pollingUntil: new Date(now.getTime() + 1000),
};
const view = viewOf(record, now);
expect(view.secretBound).toBe(true);
expect(JSON.stringify(view)).not.toContain('sealed');
expect(view.status.cursor).toBe('UID 9 · validity 7');
expect(view.status.polling).toBe(true);
expect(view.kind).toBe('imap-smtp');
if (view.kind === 'imap-smtp') expect(view.settings.imapHost).toBe('imap.exmail.qq.com');
});
});
@@ -0,0 +1,83 @@
/** 游标两形 —— domain 零 mock。IMAP 按 UID 逐封推进,wecom 按时间窗逐批推进 */
import { describe, expect, it } from 'vitest';
import {
advanceImap,
cursorSummary,
imapCursor,
imapNeedsReanchor,
newUids,
parseCursor,
wecomCursor,
wecomWindow,
WECOM_OVERLAP_SEC,
} from '../internal/domain/cursor.ts';
describe('imap 游标', () => {
it('逐封推进只往前不往后', () => {
const c = imapCursor('7', 100);
expect(advanceImap(c, 105).lastUid).toBe(105);
expect(advanceImap(c, 90).lastUid).toBe(100);
});
it('UIDVALIDITY 变了、或根本不是 imap 游标,都要重锚', () => {
expect(imapNeedsReanchor(imapCursor('7', 100), '7')).toBe(false);
expect(imapNeedsReanchor(imapCursor('7', 100), '8')).toBe(true);
expect(imapNeedsReanchor(null, '7')).toBe(true);
expect(imapNeedsReanchor(wecomCursor(1), '7')).toBe(true);
});
it('**n:* 陷阱**:服务器会把最大那一封也回回来,客户端再按 uid > lastUid 过滤一遍,并升序去重', () => {
expect(newUids([100, 103, 101, 103, 99], 100)).toEqual([101, 103]);
expect(newUids([100], 100)).toEqual([]);
});
});
describe('wecom 游标', () => {
const now = new Date('2026-08-25T10:00:00Z');
it('有游标:窗口从 lastTime 往前退十分钟(治时钟偏,重复由唯一键挡)到当下', () => {
const w = wecomWindow(wecomCursor(1_000_000), null, now);
expect(w).toEqual({ beginTime: 1_000_000 - WECOM_OVERLAP_SEC, endTime: Math.floor(now.getTime() / 1000) });
});
it('无游标且不回填:窗口空(begin = end),等锚定;设了回填就从那天起', () => {
const end = Math.floor(now.getTime() / 1000);
expect(wecomWindow(null, null, now)).toEqual({ beginTime: end, endTime: end });
const since = new Date('2026-08-20T00:00:00Z');
expect(wecomWindow(null, since, now).beginTime).toBe(Math.floor(since.getTime() / 1000));
});
it('lastTime 往前退不会退成负数', () => {
expect(wecomWindow(wecomCursor(10), null, now).beginTime).toBe(0);
});
});
describe('摘要与解析', () => {
it('人读摘要;没拉过是 null', () => {
expect(cursorSummary(null)).toBeNull();
expect(cursorSummary(imapCursor('7', 100))).toBe('UID 100 · validity 7');
expect(cursorSummary(wecomCursor(1787652000))).toBe('至 2026-08-25T10:00:00.000Z');
});
it('从 jsonb 解回来:形状不对当没有游标(重锚),不抛', () => {
expect(parseCursor({ kind: 'imap', uidValidity: '7', lastUid: 3 })).toEqual(imapCursor('7', 3));
expect(parseCursor({ kind: 'wecom', lastTime: 5 })).toEqual(wecomCursor(5));
expect(parseCursor({ kind: 'imap', lastUid: 'x' })).toBeNull();
expect(parseCursor(null)).toBeNull();
expect(parseCursor('garbage')).toBeNull();
});
it('**取值范围也算形状**:一行脏游标不许把账号页炸成 500', () => {
/*
* `cursorSummary` 跑在 `viewOf` 里:`new Date(1e13 * 1000).toISOString()` 抛 RangeError,
* 一行脏游标 → `listAccounts` 整页 500。不合格的当没有游标(下一轮重锚),不抛。
*/
for (const bad of [1e13, -1, Number.NaN, Number.POSITIVE_INFINITY, 1.5, Number.MAX_SAFE_INTEGER + 2]) {
expect(parseCursor({ kind: 'wecom', lastTime: bad })).toBeNull();
expect(parseCursor({ kind: 'imap', uidValidity: '7', lastUid: bad })).toBeNull();
}
// 1e13 是安全整数,乘 1000 却出了 Date 的界 —— 「安全整数」这一句拦不住它,上界必须自己写
expect(Number.isSafeInteger(1e13)).toBe(true);
// UID 是 32 位无符号:恰好在界内的收,越界的当没有
expect(parseCursor({ kind: 'imap', uidValidity: '7', lastUid: 0xffff_ffff })).toEqual(imapCursor('7', 0xffff_ffff));
expect(parseCursor({ kind: 'imap', uidValidity: '7', lastUid: 0x1_0000_0000 })).toBeNull();
expect(parseCursor({ kind: 'wecom', lastTime: 0 })).toEqual(wecomCursor(0));
expect(() => cursorSummary(parseCursor({ kind: 'wecom', lastTime: 1e13 }))).not.toThrow();
expect(cursorSummary(parseCursor({ kind: 'wecom', lastTime: 1e13 }))).toBeNull();
});
});
@@ -0,0 +1,17 @@
/** 分发状态机:dispatching 不能被重投;回调回 skipped 就是 skipped */
import { describe, expect, it } from 'vitest';
import { canRedeliver, stateOfOutcome } from '../internal/domain/delivery.ts';
describe('delivery', () => {
it('从没投过 / done / failed / skipped 都能重投;正在投的不能', () => {
expect(canRedeliver(undefined)).toBe(true);
expect(canRedeliver('done')).toBe(true);
expect(canRedeliver('failed')).toBe(true);
expect(canRedeliver('skipped')).toBe(true);
expect(canRedeliver('dispatching')).toBe(false);
});
it('回调的返回值定状态', () => {
expect(stateOfOutcome(undefined)).toBe('done');
expect(stateOfOutcome({ skipped: '账号未绑定通道' })).toBe('skipped');
});
});
@@ -0,0 +1,36 @@
/** 到期与租约 —— 从没拉过就到期;租约在手不算到期;租约时长下限 5 分钟 */
import { describe, expect, it } from 'vitest';
import { isDue, isPolling, leaseMsFor, MIN_LEASE_MS } from '../internal/domain/due.ts';
const now = new Date('2026-08-25T10:00:00Z');
const base = { enabled: true, lastPolledAt: null, pollIntervalSec: 30, pollingUntil: null };
describe('isDue', () => {
it('从没拉过 → 到期;停用 → 永不到期', () => {
expect(isDue(base, now)).toBe(true);
expect(isDue({ ...base, enabled: false }, now)).toBe(false);
});
it('上次 + 间隔 <= 现在才到期', () => {
expect(isDue({ ...base, lastPolledAt: new Date(now.getTime() - 29_000) }, now)).toBe(false);
expect(isDue({ ...base, lastPolledAt: new Date(now.getTime() - 30_000) }, now)).toBe(true);
});
it('租约还没过期 → 别人在拉,不到期;过期了照常', () => {
expect(isDue({ ...base, pollingUntil: new Date(now.getTime() + 1) }, now)).toBe(false);
expect(isDue({ ...base, pollingUntil: new Date(now.getTime() - 1) }, now)).toBe(true);
});
});
describe('leaseMsFor / isPolling', () => {
it('租约 = max(5 分钟, 3 × 间隔)', () => {
// 下限那一档写死 300_000,不写 MIN_LEASE_MS:拿被测的常量当期望值是自证 ——
// 哪天有人把 MIN_LEASE_MS 改成 5 秒,这条断言照样绿
expect(leaseMsFor(30)).toBe(300_000);
expect(MIN_LEASE_MS).toBe(300_000);
expect(leaseMsFor(600)).toBe(1_800_000);
});
it('polling 只在租约未过期时为真', () => {
expect(isPolling(null, now)).toBe(false);
expect(isPolling(new Date(now.getTime() + 10), now)).toBe(true);
expect(isPolling(new Date(now.getTime() - 10), now)).toBe(false);
});
});
@@ -0,0 +1,403 @@
/**
* 测试夹具 —— 内存 store(用例测试用)、假 provider(邮件通道是外界,替身允许)、EML 构造。
* 系统内部机制在 IT 里真跑(mail-store.it.test.ts);这里的内存 store 只服务用例逻辑。
*/
import type { MailAccountKind, MailProbeReport } from '@contracts';
import { isDue } from '../internal/domain/due.ts';
import { imapCursor, wecomCursor } from '../internal/domain/cursor.ts';
import type {
AttachmentStore,
Clock,
DeliveryRecord,
IdGenerator,
InboundRecord,
InboundStore,
MailAccountRecord,
MailAccountStore,
MailCursor,
MailProvider,
OutboundMessage,
OutboundRecord,
OutboundStore,
RawMessage,
SecretBox,
} from '../port.ts';
const CRLF = '\r\n';
export function fixedClock(iso = '2026-08-25T10:00:00Z'): Clock & { tick(ms: number): void } {
let t = new Date(iso).getTime();
return {
now: () => new Date(t),
tick(ms) {
t += ms;
},
};
}
export function seqIds(prefix = 'id'): IdGenerator {
let n = 0;
return {
next: () => {
n += 1;
return `${prefix}-${n}`;
},
};
}
export const plainSecrets: SecretBox = {
seal: (s) => `sealed:${s}`,
open: (c) => {
if (!c.startsWith('sealed:')) throw new Error('密文格式非法');
return c.slice('sealed:'.length);
},
};
export function account(over: Partial<MailAccountRecord> = {}): MailAccountRecord {
const now = new Date('2026-08-25T09:00:00Z');
return {
id: 'acc-1',
kind: 'imap-smtp',
name: '销售信箱',
address: 'sales@we.com',
displayName: '销售部',
enabled: true,
isDefault: true,
pollIntervalSec: 30,
channelId: 'ch-1',
backfillFrom: null,
settings: { folder: 'INBOX' },
secretCipher: `sealed:${JSON.stringify({ password: 'pw-1234' })}`,
cursor: null,
lastPolledAt: null,
lastPollError: null,
lastInboundAt: null,
pollingUntil: null,
createdAt: now,
updatedAt: now,
...over,
};
}
export function memoryAccounts(clock: Clock): MailAccountStore & { rows: Map<string, MailAccountRecord> } {
const rows = new Map<string, MailAccountRecord>();
return {
rows,
async list() {
return [...rows.values()].sort((a, b) => a.name.localeCompare(b.name));
},
async get(id) {
return rows.get(id);
},
async findByName(name) {
return [...rows.values()].find((r) => r.name === name);
},
async save(record) {
if (record.isDefault) {
for (const [k, v] of rows) if (k !== record.id && v.isDefault) rows.set(k, { ...v, isDefault: false });
}
const existing = rows.get(record.id);
/*
* 与真 store 同一条不变量:**upsert 只写配置列**。运行列(游标 / 上次拉取 / 上次错误 /
* 最近来信 / 租约)一概保留库里当下那一份 —— 整行覆盖会把另一台正持着的租约与刚推进的游标
* 盖回读那一刻的快照。替身与真身在这一条上分叉,测试就会连错的点(本项目已踩过两次)。
* 要在测试里摆运行列:用 `savePoll` / `claimPolling`,或直接动 `rows`。
*/
rows.set(
record.id,
existing === undefined
? record
: {
...record,
cursor: existing.cursor,
lastPolledAt: existing.lastPolledAt,
lastPollError: existing.lastPollError,
lastInboundAt: existing.lastInboundAt,
pollingUntil: existing.pollingUntil,
createdAt: existing.createdAt,
},
);
},
async remove(id) {
rows.delete(id);
},
async listDue(now) {
return [...rows.values()].filter((r) => isDue(r, now));
},
async claimPolling(id, leaseMs) {
const r = rows.get(id);
const now = clock.now();
if (r === undefined || !r.enabled) return false;
// 与真 store 的边界方向不同:真 SQL 是 `polling_until < now()`(恰好等于 now 仍挡住),
// 这里是 `> now`(恰好等于 now 可抢)。微秒级边界、两边 now 的来源本就不同,不改行为 —— 只记差异。
if (r.pollingUntil !== null && r.pollingUntil.getTime() > now.getTime()) return false;
rows.set(id, { ...r, pollingUntil: new Date(now.getTime() + leaseMs) });
return true;
},
async releasePolling(id) {
const r = rows.get(id);
if (r !== undefined) rows.set(id, { ...r, pollingUntil: null });
},
async savePoll(id, patch) {
const r = rows.get(id);
if (r === undefined) return;
rows.set(id, {
...r,
...(patch.cursor === undefined ? {} : { cursor: patch.cursor }),
...(patch.polledAt === undefined ? {} : { lastPolledAt: patch.polledAt }),
...(patch.error === undefined ? {} : { lastPollError: patch.error }),
...(patch.lastInboundAt === undefined ? {} : { lastInboundAt: patch.lastInboundAt }),
...(patch.address === undefined ? {} : { address: patch.address }),
});
},
};
}
/**
* 与真 store 同款:列表与只要元信息的读**不带 `raw` 原件字节**(只有 `get()` 带)。
* 「有没有原件」一律问 `rawAvailable` —— 替身若照旧把字节递出来,
* 「列表不搬原件」这条就没有任何测试拦得住了。
*/
function withoutRaw(record: InboundRecord): InboundRecord {
return { ...record, raw: null };
}
export function memoryInbound(): InboundStore & { rows: Map<string, InboundRecord>; deliveries: Map<string, DeliveryRecord> } {
const rows = new Map<string, InboundRecord>();
const deliveries = new Map<string, DeliveryRecord>();
// 与 DB store 同序:按订阅方名排(测试里对顺序有断言)。
// `localeCompare` vs 真 store 的 PG `orderBy(subscriber)`(依 collation)——
// 订阅方名都是 ASCII 标识符时两者一致,前提仅此而已。
const of = (mailId: string): DeliveryRecord[] =>
[...deliveries.values()].filter((d) => d.mailId === mailId).sort((a, b) => a.subscriber.localeCompare(b.subscriber));
return {
rows,
deliveries,
async insert(record) {
for (const r of rows.values()) {
if (r.accountId === record.accountId && (r.messageId === record.messageId || r.providerRef === record.providerRef)) {
return { inserted: false };
}
}
rows.set(record.id, record);
return { inserted: true };
},
async knownProviderRefs(accountId, refs) {
return new Set([...rows.values()].filter((r) => r.accountId === accountId && refs.includes(r.providerRef)).map((r) => r.providerRef));
},
async list(query) {
return [...rows.values()]
.map(withoutRaw)
.filter((r) => query.accountId === undefined || r.accountId === query.accountId)
// 与真 store 同序:两级键 desc(receivedAt), desc(createdAt) —— 并列时后创建的排前。
// 少了第二级,并列会按 Map 插入序解(= createdAt 升序),与真身**方向相反**,
// 而 rawMessage() 的 receivedAt 默认是同一个常量,并列是常态不是例外。
.sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime() || b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, query.limit)
.map((record) => ({ record, deliveries: of(record.id) }));
},
async getMeta(mailId) {
const record = rows.get(mailId);
return record === undefined ? undefined : { record: withoutRaw(record), deliveries: of(mailId) };
},
async get(mailId) {
const record = rows.get(mailId);
return record === undefined ? undefined : { record, deliveries: of(mailId) };
},
async setDelivery(delivery) {
deliveries.set(`${delivery.mailId}|${delivery.subscriber}`, delivery);
},
};
}
export function memoryOutbound(): OutboundStore & { rows: Map<string, OutboundRecord> } {
const rows = new Map<string, OutboundRecord>();
return {
rows,
async insert(record) {
if (record.idempotencyKey !== null && [...rows.values()].some((r) => r.idempotencyKey === record.idempotencyKey)) {
throw new Error('duplicate key value violates unique constraint "mail_outbound_idem_key"');
}
rows.set(record.id, record);
},
async byIdempotencyKey(key) {
return [...rows.values()].find((r) => r.idempotencyKey === key);
},
async claimForResend(id, opts) {
// 与真 store 同一句条件:failed 可抢;sending 只有停够了才算「上次崩在半路」。
// 抢到即置 sending 并把 createdAt 推到 now —— 那一行当场离开可抢的集合,第二个人抢不到。
const r = rows.get(id);
if (r === undefined) return false;
const claimable = r.state === 'failed' || (r.state === 'sending' && r.createdAt.getTime() <= opts.staleBefore.getTime());
if (!claimable) return false;
rows.set(id, { ...r, state: 'sending', createdAt: opts.now, error: null });
return true;
},
async setState(id, state, patch) {
const r = rows.get(id);
if (r === undefined) return;
rows.set(id, {
...r,
state,
// 与真 store 同一条不变量:发成功的那一行不留上一次的错误话(替身与真身分叉,测试就会连错的点)
...(state === 'sent' ? { error: null } : {}),
...(patch.error === undefined ? {} : { error: patch.error }),
...(patch.providerRef === undefined ? {} : { providerRef: patch.providerRef }),
...(patch.sentAt === undefined ? {} : { sentAt: patch.sentAt }),
});
},
async list(query) {
return [...rows.values()]
.filter((r) => query.accountId === undefined || r.accountId === query.accountId)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, query.limit);
},
};
}
export function memoryAttachments(): AttachmentStore & { files: Map<string, { name: string; contentType: string; bytes: Uint8Array; note: string }> } {
const files = new Map<string, { name: string; contentType: string; bytes: Uint8Array; note: string }>();
let n = 0;
return {
files,
async put(file) {
n += 1;
const ref = `up-${n}`;
files.set(ref, { ...file });
return { ref };
},
async get(ref) {
const f = files.get(ref);
return f === undefined ? undefined : { name: f.name, contentType: f.contentType, bytes: f.bytes };
},
};
}
export type FakeProvider = MailProvider & {
readonly sent: OutboundMessage[];
readonly pullCalls: Array<{ cursor: MailCursor; limit: number }>;
queue(...messages: RawMessage[]): void;
/**
* 让 `pull` 从第几次调用开始报错(1-based 调用计数;`afterCalls` 缺省 0 = 第一次调用就炸,
* 与既有用例的期望一致)。**中途失败**才验得出「游标停在最后一封成功处」——
* 若失败恒发生在第一次调用,游标"没动"只是因为它压根没机会动,测不出逐封推进的行为。
*/
failPullWith(error: Error | undefined, opts?: { readonly afterCalls?: number }): void;
failSendWith(error: Error | undefined): void;
probeWith(report: MailProbeReport | undefined): void;
/** 这一轮带回一句「不算失败但人得知道」的话(真身:UIDVALIDITY 变化已重锚) */
noteWith(note: string | undefined): void;
};
/** 可脚本化的假 provider:排队的信按 limit 交出去,IMAP 形逐封带 cursorAfter */
export function fakeProvider(kind: MailAccountKind = 'imap-smtp'): FakeProvider {
const pending: RawMessage[] = [];
const sent: OutboundMessage[] = [];
const pullCalls: Array<{ cursor: MailCursor; limit: number }> = [];
let pullError: Error | undefined;
let pullFailAfter = 0;
let sendError: Error | undefined;
let probeReport: MailProbeReport | undefined;
let note: string | undefined;
let uidSeq = 0;
return {
kind,
sent,
pullCalls,
queue(...messages) {
pending.push(...messages);
},
failPullWith(error, opts = {}) {
pullError = error;
pullFailAfter = opts.afterCalls ?? 0;
},
failSendWith(error) {
sendError = error;
},
probeWith(report) {
probeReport = report;
},
noteWith(value) {
note = value;
},
async probe(accountArg) {
return (
probeReport ?? {
kind,
address: accountArg.address,
receive: { ok: true, latencyMs: 1 },
send: kind === 'wecom' ? { checked: false, note: '发信权限只能在真发时验证' } : { checked: true, ok: true },
}
);
},
async anchor(_accountArg, _backfillFrom, now) {
return kind === 'imap-smtp' ? imapCursor('v1', 0) : wecomCursor(Math.floor(now.getTime() / 1000));
},
async pull(_accountArg, cursor, opts) {
pullCalls.push({ cursor, limit: opts.limit });
if (pullError !== undefined && pullCalls.length > pullFailAfter) throw pullError;
const batch = pending.splice(0, opts.limit).map((m) => {
if (kind !== 'imap-smtp' || m.cursorAfter !== undefined) return m;
uidSeq += 1;
return { ...m, cursorAfter: imapCursor('v1', uidSeq) };
});
const last = batch.at(-1)?.cursorAfter;
const next = kind === 'imap-smtp' ? (last ?? cursor) : wecomCursor(Math.floor(opts.now.getTime() / 1000));
return { messages: batch, cursor: next, more: pending.length > 0, ...(note === undefined ? {} : { note }) };
},
async send(_accountArg, message) {
if (sendError !== undefined) throw sendError;
sent.push(message);
return { providerRef: `fake:${sent.length}` };
},
};
}
export function rawMessage(providerRef: string, eml: Uint8Array, receivedAt = new Date('2026-08-25T09:30:00Z')): RawMessage {
return { providerRef, eml, receivedAt };
}
/** 极简 RFC822 构造:够 postal-mime 解出地址、主题、正文、附件 */
export function eml(parts: {
from?: string;
to?: string;
cc?: string;
subject?: string;
text?: string;
html?: string;
messageId?: string;
inReplyTo?: string;
references?: string;
date?: string;
attachment?: { name: string; contentType: string; content: string };
}): Uint8Array {
const headers = [
`From: ${parts.from ?? 'buyer@acme.com'}`,
`To: ${parts.to ?? 'sales@we.com'}`,
...(parts.cc === undefined ? [] : [`Cc: ${parts.cc}`]),
`Subject: ${parts.subject ?? '询价'}`,
...(parts.messageId === undefined ? [] : [`Message-ID: ${parts.messageId}`]),
...(parts.inReplyTo === undefined ? [] : [`In-Reply-To: ${parts.inReplyTo}`]),
...(parts.references === undefined ? [] : [`References: ${parts.references}`]),
`Date: ${parts.date ?? 'Tue, 25 Aug 2026 09:00:00 +0800'}`,
'MIME-Version: 1.0',
];
const text = parts.text ?? '你好,请报价';
if (parts.html === undefined && parts.attachment === undefined) {
return new TextEncoder().encode([...headers, 'Content-Type: text/plain; charset=utf-8', '', text, ''].join(CRLF));
}
const lines = [...headers, 'Content-Type: multipart/mixed; boundary="B1"', '', '--B1', 'Content-Type: text/plain; charset=utf-8', '', text];
if (parts.html !== undefined) lines.push('--B1', 'Content-Type: text/html; charset=utf-8', '', parts.html);
if (parts.attachment !== undefined) {
lines.push(
'--B1',
`Content-Type: ${parts.attachment.contentType}; name="${parts.attachment.name}"`,
`Content-Disposition: attachment; filename="${parts.attachment.name}"`,
'Content-Transfer-Encoding: base64',
'',
Buffer.from(parts.attachment.content, 'utf8').toString('base64'),
);
}
lines.push('--B1--', '');
return new TextEncoder().encode(lines.join(CRLF));
}
@@ -0,0 +1,79 @@
/** IMAP/SMTP 适配器里不碰网络的那几段:设置默认值、内部时间兜底、fetch 结果排序、nodemailer 入参映射 */
import { describe, expect, it } from 'vitest';
import { imapSettingsOf, internalDateOf, lowestUid, mailOptionsOf, orderFetched } from '../internal/adapter/imap-smtp-provider.ts';
import { imapCursor } from '../internal/domain/cursor.ts';
const account = { id: 'a', kind: 'imap-smtp' as const, address: 'sales@we.com', displayName: '销售部', settings: {}, secret: { password: 'pw' } };
describe('imapSettingsOf', () => {
it('空 settings 取腾讯默认;登录名缺省 = 地址', () => {
const s = imapSettingsOf(account);
expect(s.settings.imapHost).toBe('imap.exmail.qq.com');
expect(s.settings.smtpPort).toBe(465);
expect(s.login).toBe('sales@we.com');
expect(imapSettingsOf({ ...account, settings: { login: 'other@we.com' } }).login).toBe('other@we.com');
});
it('缺密码当场说清', () => {
expect(() => imapSettingsOf({ ...account, secret: {} })).toThrow('缺客户端专用密码');
});
});
describe('internalDateOf', () => {
const fallback = new Date('2026-08-25T10:00:00Z');
it('Date 原样;字符串解得出就用;解不出 / 缺席用兜底', () => {
const d = new Date('2026-08-25T09:00:00Z');
expect(internalDateOf(d, fallback)).toBe(d);
expect(internalDateOf('2026-08-25T09:00:00Z', fallback).toISOString()).toBe('2026-08-25T09:00:00.000Z');
expect(internalDateOf('garbage', fallback)).toBe(fallback);
expect(internalDateOf(undefined, fallback)).toBe(fallback);
});
});
describe('orderFetched', () => {
it('按 UID 升序,cursorAfter 就是自己的 UID;没 source 的丢', () => {
const rows = orderFetched('7', [
{ uid: 12, source: Buffer.from('b'), internalDate: undefined },
{ uid: 10, source: Buffer.from('a'), internalDate: undefined },
{ uid: 11, source: undefined, internalDate: undefined },
], new Date('2026-08-25T10:00:00Z'));
expect(rows.map((r) => r.providerRef)).toEqual(['7:10', '7:12']);
expect(rows[1]?.cursorAfter).toEqual(imapCursor('7', 12));
expect(rows[0]?.eml).toBeInstanceOf(Uint8Array);
});
});
describe('lowestUid', () => {
it('取最小;空结果回兜底;**十万级不栈溢出**(Math.min(...uids) 会)', () => {
expect(lowestUid([12, 7, 30], 99)).toBe(7);
expect(lowestUid([], 99)).toBe(99);
expect(lowestUid([100], 3)).toBe(3); // 兜底比数组里的都小时,兜底赢(它是 uidNext,只在空集时才该赢)
const many = Array.from({ length: 200_000 }, (_, i) => i + 5);
expect(() => Math.min(...many)).toThrow(RangeError); // 反证:铺参数那一手在这个量级真的炸
expect(lowestUid(many, Number.MAX_SAFE_INTEGER)).toBe(5);
});
});
describe('mailOptionsOf', () => {
it('Message-ID / 显示名 / 附件字节全部带上;没 html 就不带 html 键', () => {
const options = mailOptionsOf({
messageId: '<o1@we.com>',
from: { address: 'sales@we.com', name: '销售部' },
to: ['b@acme.com'],
cc: [],
bcc: ['c@we.com'],
subject: '报价',
text: '正文',
inReplyTo: '<m1@acme.com>',
references: ['<m1@acme.com>'],
attachments: [{ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]) }],
});
expect(options.messageId).toBe('<o1@we.com>');
expect(options.from).toEqual({ name: '销售部', address: 'sales@we.com' });
expect(options.bcc).toEqual(['c@we.com']);
expect(options.inReplyTo).toBe('<m1@acme.com>');
expect(options.references).toEqual(['<m1@acme.com>']);
expect('html' in options).toBe(false);
expect(options.attachments?.[0]).toMatchObject({ filename: 'q.pdf', contentType: 'application/pdf' });
expect(Buffer.isBuffer(options.attachments?.[0]?.content)).toBe(true);
});
});
@@ -0,0 +1,63 @@
/**
* 真信箱集成测试 —— 只在给了 JCAI_MAIL_IT_IMAP_USER / _PASS 时跑(缺即跳过并告警)。
* JCAI_MAIL_IT_IMAP_HOST(缺省 imap.exmail.qq.com)· JCAI_MAIL_IT_SMTP_HOST(缺省 smtp.exmail.qq.com)
* JCAI_MAIL_IT_SEND_TO:给了才真发一封(发给自己最省事)
*/
import { describe, expect, it } from 'vitest';
import { createImapSmtpProvider } from '../internal/adapter/imap-smtp-provider.ts';
const user = process.env['JCAI_MAIL_IT_IMAP_USER'];
const pass = process.env['JCAI_MAIL_IT_IMAP_PASS'];
const enabled = user !== undefined && pass !== undefined;
if (!enabled) process.stderr.write('⚠️ IMAP/SMTP 真信箱集成测试跳过:未设 JCAI_MAIL_IT_IMAP_USER / JCAI_MAIL_IT_IMAP_PASS\n');
const account = {
id: 'it',
kind: 'imap-smtp' as const,
address: user ?? '',
displayName: 'JCAI IT',
settings: {
imapHost: process.env['JCAI_MAIL_IT_IMAP_HOST'] ?? 'imap.exmail.qq.com',
smtpHost: process.env['JCAI_MAIL_IT_SMTP_HOST'] ?? 'smtp.exmail.qq.com',
},
secret: { password: pass ?? '' },
};
const known = async (): Promise<Set<string>> => new Set<string>();
describe.skipIf(!enabled)('IMAP/SMTP provider(真信箱)', () => {
const provider = createImapSmtpProvider();
it('探测:收发都 ok', async () => {
const report = await provider.probe(account);
expect(report.receive).toMatchObject({ ok: true });
expect(report.send).toMatchObject({ checked: true, ok: true });
}, 60_000);
it('锚定在当下;紧接着拉一轮是空的;回填 7 天能拉到东西(或至少不抛)', async () => {
const now = new Date();
const cursor = await provider.anchor(account, null, now);
expect(cursor.kind).toBe('imap');
const empty = await provider.pull(account, cursor, { limit: 5, now, backfillFrom: null, known });
expect(empty.messages).toEqual([]);
const since = new Date(now.getTime() - 7 * 86_400_000);
const back = await provider.anchor(account, since, now);
const some = await provider.pull(account, back, { limit: 3, now, backfillFrom: since, known });
expect(some.messages.length).toBeLessThanOrEqual(3);
for (const m of some.messages) expect(m.eml.byteLength).toBeGreaterThan(0);
}, 120_000);
it.skipIf(process.env['JCAI_MAIL_IT_SEND_TO'] === undefined)('真发一封', async () => {
const result = await provider.send(account, {
messageId: `<it-${Date.now()}@${account.address.split('@')[1] ?? 'local'}>`,
from: { address: account.address, name: 'JCAI IT' },
to: [process.env['JCAI_MAIL_IT_SEND_TO'] ?? ''],
cc: [],
bcc: [],
subject: 'JCAI mail IT',
text: '这是一封集成测试信',
references: [],
attachments: [],
});
expect(result.providerRef ?? '').toContain('250');
}, 60_000);
});
@@ -0,0 +1,203 @@
/** 解析结果 → 入站记录:缺 Message-ID 顶上、线程取 In-Reply-To、纯 html 剥标签、附件按上限分「存」与「留注」 */
import { describe, expect, it } from 'vitest';
import type { ParsedMail } from '../internal/domain/records.ts';
import {
ATTACHMENT_CAP,
bodyTextOf,
buildInboundRecord,
detailOf,
fallbackMessageId,
planAttachments,
rawOf,
stripHtml,
stripNul,
summaryOf,
textPreviewOf,
threadIdOf,
} from '../internal/domain/inbound-record.ts';
const parsed: ParsedMail = {
messageId: '<m1@acme.com>',
inReplyTo: '<q1@we.com>',
references: ['<root@we.com>', '<q1@we.com>'],
from: { address: 'buyer@acme.com', name: '张三' },
to: [{ address: 'sales@we.com', name: '' }],
cc: [],
subject: '询价 RFQ-1',
text: '你好,请报价',
html: '<p>你好,请报价</p>',
date: new Date('2026-08-25T09:00:00Z'),
attachments: [{ name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', bytes: new Uint8Array(10) }],
};
const now = new Date('2026-08-25T10:00:00Z');
describe('线程与正文', () => {
it('**线程 = 这一串的根**(References[0]);只给 In-Reply-To 时才退回它;都没有就是 undefined', () => {
/*
* 2026-08-25 终审改口径:原先取的是最近一跳(inReplyTo ?? references.at(-1)),
* 与 ADR-013 决策 3 和 app 侧接线正相反 —— 跑一个来回归属①级就漂掉。
* 断言随语义更新(测试没删,期望值改对):同一封信 parsed 现在给的是根 <root@we.com>。
*/
expect(threadIdOf(parsed)).toBe('<root@we.com>');
expect(threadIdOf({ references: ['<a>', '<b>'] })).toBe('<a>');
expect(threadIdOf({ references: [], inReplyTo: '<only-hop>' })).toBe('<only-hop>');
expect(threadIdOf({ references: [] })).toBeUndefined();
// 入站详情那一形用 null 表示「没有」—— 同一个函数要接得住(app 的 mail-wiring 传的就是它)
expect(threadIdOf({ references: [], inReplyTo: null })).toBeUndefined();
});
it('缺 Message-ID 用 providerRef@域顶上,不抛', () => {
expect(fallbackMessageId('uid:7:12', 'sales@we.com')).toBe('<uid:7:12@we.com>');
expect(fallbackMessageId('x', 'no-at')).toBe('<x@local>');
});
it('纯 html 的信剥标签当正文;script / style 整段丢;实体解回来;空白收成一个', () => {
expect(stripHtml('<style>p{}</style><p>你好&amp;<b>世界</b></p>\n<script>x()</script> 再见')).toBe('你好&世界 再见');
expect(bodyTextOf({ html: '<div>只有 html</div>' })).toBe('只有 html');
expect(bodyTextOf({ text: '有 text', html: '<i>忽略</i>' })).toBe('有 text');
expect(bodyTextOf({})).toBe('');
});
it('预览 200 字', () => {
expect(textPreviewOf('a'.repeat(300), null)).toHaveLength(200);
expect(textPreviewOf(null, '<p>来自 html</p>')).toBe('来自 html');
});
});
describe('附件与原件', () => {
it('超上限的附件不存,留注;下标按原顺序', () => {
const plan = planAttachments(
[
{ name: 'a.pdf', contentType: 'application/pdf', bytes: new Uint8Array(3) },
{ name: 'big.zip', contentType: 'application/zip', bytes: new Uint8Array(ATTACHMENT_CAP + 1) },
],
ATTACHMENT_CAP,
);
expect(plan.map((p) => [p.index, p.store])).toEqual([
[0, true],
[1, false],
]);
expect(plan[1]!.note).toContain('8MB');
});
it('原件超上限留空 + 注', () => {
expect(rawOf(new Uint8Array(4), 8)).toEqual({ raw: new Uint8Array(4), note: '' });
const big = rawOf(new Uint8Array(9), 8);
expect(big.raw).toBeNull();
expect(big.note).not.toBe('');
});
});
describe('NUL(U+0000)一律剥掉', () => {
/*
* PG 的 text / jsonb 列拒收 NUL,而 MIME 解出二进制垃圾时它真会出现。
* 不剥的话这一封每 30 秒炸一次、游标停在它前面,后面所有新信永远进不来。
*/
const NUL = '\u0000';
it('纯函数:有就剥,没有就原样(同一个引用)', () => {
expect(stripNul(`a${NUL}b`)).toBe('ab');
const clean = '干净的一句';
expect(stripNul(clean)).toBe(clean);
expect(stripNul(NUL)).toBe('');
});
it('落 text / jsonb 的字段全剥:主题 / 正文 / html / 地址 / 头部串 / 附件名', () => {
const dirty = buildInboundRecord({
id: 'in-nul',
accountId: 'acc-1',
address: 'sales@we.com',
providerRef: `uid:7:${NUL}14`,
parsed: {
messageId: `<m${NUL}1@acme.com>`,
inReplyTo: `<q${NUL}1@we.com>`,
references: [`<root${NUL}@we.com>`],
from: { address: `buyer${NUL}@acme.com`, name: `张${NUL}三` },
to: [{ address: `sales${NUL}@we.com`, name: '' }],
cc: [{ address: `cc${NUL}@we.com`, name: '' }],
subject: `询价${NUL}RFQ-1`,
text: `正文${NUL}一`,
html: `<p>${NUL}二</p>`,
attachments: [],
},
eml: new Uint8Array([0, 1, 0]),
receivedAt: now,
attachments: [{ index: 0, name: `bom${NUL}.xlsx`, contentType: `application/${NUL}zip`, size: 3, ref: null, note: `注${NUL}` }],
now,
});
expect(JSON.stringify(dirty)).not.toContain('\\u0000');
expect(dirty.subject).toBe('询价RFQ-1');
expect(dirty.text).toBe('正文一');
expect(dirty.html).toBe('<p>二</p>');
expect(dirty.messageId).toBe('<m1@acme.com>');
expect(dirty.providerRef).toBe('uid:7:14');
expect(dirty.inReplyTo).toBe('<q1@we.com>');
expect(dirty.references).toEqual(['<root@we.com>']);
expect(dirty.from).toEqual({ address: 'buyer@acme.com', name: '张三' });
expect(dirty.to[0]?.address).toBe('sales@we.com');
expect(dirty.cc[0]?.address).toBe('cc@we.com');
expect(dirty.attachments[0]).toMatchObject({ name: 'bom.xlsx', contentType: 'application/zip', note: '注' });
// 原件是 bytea:一个字节都不动
expect(dirty.raw).toEqual(new Uint8Array([0, 1, 0]));
});
it('缺 Message-ID 时顶上的那一串也剥过(它进唯一键)', () => {
const record = buildInboundRecord({
id: 'in-nul-2',
accountId: 'acc-1',
address: 'sales@we.com',
providerRef: `uid:7:${NUL}15`,
parsed: { references: [], to: [], cc: [], subject: '', attachments: [] },
eml: new Uint8Array(),
receivedAt: now,
attachments: [],
now,
});
expect(record.messageId).toBe('<uid:7:15@we.com>');
});
});
describe('buildInboundRecord / 视图', () => {
const record = buildInboundRecord({
id: 'in-1',
accountId: 'acc-1',
address: 'sales@we.com',
providerRef: 'uid:7:12',
parsed,
eml: new Uint8Array([1, 2, 3]),
receivedAt: now,
attachments: [{ index: 0, name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1', note: '' }],
now,
});
it('记录照抄解析结果,raw 存下来,size 是原件字节数', () => {
expect(record.messageId).toBe('<m1@acme.com>');
expect(record.inReplyTo).toBe('<q1@we.com>');
expect(record.from).toEqual({ address: 'buyer@acme.com', name: '张三' });
expect(record.size).toBe(3);
expect(record.raw).toEqual(new Uint8Array([1, 2, 3]));
expect(record.sentAt?.toISOString()).toBe('2026-08-25T09:00:00.000Z');
});
it('缺 from / 缺 Message-ID 也能落记录', () => {
const bare = buildInboundRecord({
id: 'in-2',
accountId: 'acc-1',
address: 'sales@we.com',
providerRef: 'uid:7:13',
parsed: { references: [], to: [], cc: [], subject: '', attachments: [] },
eml: new Uint8Array(),
receivedAt: now,
attachments: [],
now,
});
expect(bare.messageId).toBe('<uid:7:13@we.com>');
expect(bare.from).toEqual({ address: '', name: '' });
});
it('摘要不带正文只带预览;详情带正文、rawAvailable、投递', () => {
const deliveries = [{ mailId: 'in-1', subscriber: 'channel', state: 'done' as const, error: null, startedAt: now, finishedAt: now }];
const summary = summaryOf(record, deliveries);
expect(summary.textPreview).toBe('你好,请报价');
expect(summary.hasHtml).toBe(true);
expect(summary.deliveries[0]).toEqual({ subscriber: 'channel', state: 'done', error: null, startedAt: now.toISOString(), finishedAt: now.toISOString() });
expect('text' in summary).toBe(false);
const detail = detailOf(record, deliveries);
expect(detail.text).toBe('你好,请报价');
expect(detail.rawAvailable).toBe(true);
expect(detail.references).toEqual(['<root@we.com>', '<q1@we.com>']);
});
});
@@ -0,0 +1,100 @@
/** 账号用例:建 / 改 / 删 / 探测;密文只进库不出接口;名字唯一;secret 缺省保留;kind 变了游标清空 */
import { describe, expect, it } from 'vitest';
import { mailAccountInputSchema } from '@contracts';
import { MailAccountNameTakenError, MailAccountNotFoundError, MailSecretMissingError } from '../contract.ts';
import { createMailService } from '../internal/usecase/mail-service.ts';
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
import { fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts';
function harness() {
const clock = fixedClock();
const accounts = memoryAccounts(clock);
const imap = fakeProvider('imap-smtp');
const wecom = fakeProvider('wecom');
const changes: string[] = [];
const service = createMailService({
accounts,
inbound: memoryInbound(),
outbound: memoryOutbound(),
attachments: memoryAttachments(),
providers: { 'imap-smtp': imap, wecom },
mime: createPostalMimeParser(),
secrets: plainSecrets,
clock,
ids: seqIds('acc'),
notify: { changed: (r) => void changes.push(r) },
});
return { service, accounts, imap, wecom, clock, changes };
}
// 入参是判别联合:`{...imapInput, address}` 若停在联合上,TS 会拿 wecom 那一形来判超额属性(它没有 address)。
// 先窄到 imap-smtp 这一形,下面各处展开才成立 —— 窄的是夹具的静态型,值一个字节没变。
const parsedImapInput = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售', address: 'sales@we.com', isDefault: true, secret: { password: 'pw-1234' } });
if (parsedImapInput.kind !== 'imap-smtp') throw new Error('夹具:imapInput 应是 imap-smtp 那一形');
const imapInput = parsedImapInput;
describe('账号', () => {
it('建:视图不带密文、secretBound 真;库里密文是密封过的', async () => {
const { service, accounts } = harness();
const view = await service.createAccount(imapInput);
expect(view.id).toBe('acc-1');
expect(view.secretBound).toBe(true);
expect(JSON.stringify(view)).not.toContain('pw-1234');
expect(accounts.rows.get('acc-1')?.secretCipher).toBe(`sealed:${JSON.stringify({ password: 'pw-1234' })}`);
expect((await service.listAccounts()).map((a) => a.name)).toEqual(['销售']);
});
it('名字唯一:重名 409;改名撞别人也 409', async () => {
const { service } = harness();
await service.createAccount(imapInput);
await expect(service.createAccount(imapInput)).rejects.toBeInstanceOf(MailAccountNameTakenError);
const second = await service.createAccount({ ...imapInput, name: '采购', address: 'buy@we.com' });
await expect(service.updateAccount(second.id, { ...imapInput, name: '销售' })).rejects.toBeInstanceOf(MailAccountNameTakenError);
});
it('改:secret 缺省保留原密文;给了就换;换 kind 没给 secret 时旧密文作废', async () => {
const { service, accounts } = harness();
const created = await service.createAccount(imapInput);
await service.updateAccount(created.id, { ...imapInput, secret: undefined, displayName: '销售一部' });
expect(accounts.rows.get(created.id)?.secretCipher).toContain('pw-1234');
await service.updateAccount(created.id, { ...imapInput, secret: { password: 'new-pw-9' } });
expect(accounts.rows.get(created.id)?.secretCipher).toContain('new-pw-9');
const wecomInput = mailAccountInputSchema.parse({ kind: 'wecom', name: '销售', settings: { corpId: 'ww1' } });
const switched = await service.updateAccount(created.id, wecomInput);
expect(switched.secretBound).toBe(false);
expect(switched.address).toBe('');
});
it('默认账号只有一个;删了就没了(404)', async () => {
const { service } = harness();
const a = await service.createAccount(imapInput);
const b = await service.createAccount({ ...imapInput, name: '采购', address: 'buy@we.com', isDefault: true });
expect((await service.getAccount(a.id)).isDefault).toBe(false);
expect((await service.getAccount(b.id)).isDefault).toBe(true);
await service.deleteAccount(a.id);
await expect(service.getAccount(a.id)).rejects.toBeInstanceOf(MailAccountNotFoundError);
await expect(service.deleteAccount(a.id)).rejects.toBeInstanceOf(MailAccountNotFoundError);
});
it('探测(已存):用库里凭证,报告里的厂商原话脱敏;wecom 探到地址回填', async () => {
const { service, wecom, imap } = harness();
imap.probeWith({ kind: 'imap-smtp', receive: { ok: false, error: 'LOGIN sales@we.com pw-1234 failed' }, send: { checked: true, ok: true } });
const a = await service.createAccount(imapInput);
const report = await service.probe({ accountId: a.id });
expect(report.receive.error).toBe('LOGIN sales@we.com *** failed');
const w = await service.createAccount(mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' }, secret: { corpSecret: 'cs-9999' } }));
wecom.probeWith({ kind: 'wecom', address: 'app@corp.wecom.work', receive: { ok: true }, send: { checked: false, note: '发信权限只能在真发时验证' } });
await service.probe({ accountId: w.id });
expect((await service.getAccount(w.id)).address).toBe('app@corp.wecom.work');
});
it('探测(草稿):不落库;没 secret 又没 accountId → 412;给了 accountId 就借库里的密文', async () => {
const { service, accounts } = harness();
const report = await service.probe({ input: imapInput });
expect(report.receive.ok).toBe(true);
expect(accounts.rows.size).toBe(0);
await expect(service.probe({ input: { ...imapInput, secret: undefined } })).rejects.toBeInstanceOf(MailSecretMissingError);
const a = await service.createAccount(imapInput);
const borrowed = await service.probe({ input: { ...imapInput, secret: undefined, displayName: '改过' }, accountId: a.id });
expect(borrowed.receive.ok).toBe(true);
});
});
@@ -0,0 +1,208 @@
/**
* 这一份钉的是 Task 9 评审逮出的三条 Important 与一条设计律 ——
* 它们都在拉取 / 发信 / 分发那几条**当时还没有测试**的路上。
*/
import { describe, expect, it } from 'vitest';
import { mailSendInputSchema } from '@contracts';
import { createMailService } from '../internal/usecase/mail-service.ts';
import { buildInboundRecord } from '../internal/domain/inbound-record.ts';
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
import type { OutboundRecord, ParsedMail } from '../port.ts';
import { account, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts';
function harness() {
const clock = fixedClock();
const accounts = memoryAccounts(clock);
const inbound = memoryInbound();
const outbound = memoryOutbound();
const imap = fakeProvider('imap-smtp');
const wecom = fakeProvider('wecom');
const changes: string[] = [];
const service = createMailService({
accounts,
inbound,
outbound,
attachments: memoryAttachments(),
providers: { 'imap-smtp': imap, wecom },
mime: createPostalMimeParser(),
secrets: plainSecrets,
clock,
ids: seqIds('out'),
notify: { changed: (r) => void changes.push(r) },
});
return { service, accounts, inbound, outbound, imap, wecom, clock, changes };
}
const sendInput = mailSendInputSchema.parse({ to: ['buyer@acme.com'], subject: '报价', text: '见附', requestedBy: { kind: 'ops' } });
function outboundRow(over: Partial<OutboundRecord> = {}): OutboundRecord {
return {
id: 'out-9',
accountId: 'acc-1',
messageId: '<out-9@we.com>',
idempotencyKey: 'k-1',
to: ['buyer@acme.com'],
cc: [],
bcc: [],
subject: '报价',
text: '见附',
html: null,
inReplyTo: null,
references: [],
attachments: [],
state: 'sending',
error: null,
providerRef: null,
requestedBy: { kind: 'ops' },
createdAt: new Date('2026-08-25T10:00:00Z'),
sentAt: null,
...over,
};
}
describe('凭证解不开', () => {
it('密文解得开但不是 JSON:错误话里没有明文,只说「未配置凭证」', async () => {
const { service, accounts, imap } = harness();
// 解得开(plainSecrets 会回出 `pw-1234`),但那不是 JSON —— JSON.parse 的 SyntaxError 把原文嵌在 message 里
await accounts.save(account({ secretCipher: 'sealed:pw-1234' }));
const outcome = await service.pollNow('acc-1');
expect(outcome.error).not.toContain('pw-1234');
expect(outcome.error).toContain('未配置凭证');
expect(accounts.rows.get('acc-1')?.lastPollError).not.toContain('pw-1234');
expect(accounts.rows.get('acc-1')?.lastPollError).toContain('未配置凭证');
// 凭证都没打开,一次都不该去够厂商
expect(imap.pullCalls).toEqual([]);
});
});
describe('幂等键', () => {
it('同键的上一封还在飞:不发第二封,如实回没发出去', async () => {
const { service, accounts, outbound, imap, clock } = harness();
await accounts.save(account());
await outbound.insert(outboundRow({ createdAt: clock.now() }));
const result = await service.send({ ...sendInput, idempotencyKey: 'k-1' });
expect(result.state).toBe('failed');
expect(result.error).toContain('仍在发送中');
expect(imap.sent).toHaveLength(0);
expect(outbound.rows.size).toBe(1);
expect(outbound.rows.get('out-9')?.state).toBe('sending');
});
it('同键的 sending 停太久:当上次崩在半路,复用那一行重发', async () => {
const { service, accounts, outbound, imap, clock } = harness();
await accounts.save(account());
await outbound.insert(outboundRow({ createdAt: new Date(clock.now().getTime() - 6 * 60_000) }));
const result = await service.send({ ...sendInput, idempotencyKey: 'k-1' });
expect(result.state).toBe('sent');
expect(imap.sent).toHaveLength(1);
expect(outbound.rows.get('out-9')?.state).toBe('sent');
// 复用,不是新开一行
expect(outbound.rows.size).toBe(1);
expect(result.outboundId).toBe('out-9');
});
it('**并发复用要抢**:同键两路同时重发,真信只出一封,另一路如实回没发出去', async () => {
/*
* 本仓是两个后端连同一个库:两台同时重放同一次放行,都会读到同一行 `failed`、都判「可以复用」。
* 没有那一句条件更新的话,两台都发 —— **两封真邮件**,直接破掉契约白纸黑字的「同键不发两封」。
*/
const { service, accounts, outbound, imap } = harness();
await accounts.save(account());
await outbound.insert(outboundRow({ state: 'failed', error: '上一次没发出去' }));
const [a, b] = await Promise.all([
service.send({ ...sendInput, idempotencyKey: 'k-1' }),
service.send({ ...sendInput, idempotencyKey: 'k-1' }),
]);
expect(imap.sent).toHaveLength(1);
const states = [a.state, b.state].sort();
expect(states).toEqual(['failed', 'sent']);
const loser = a.state === 'failed' ? a : b;
expect(loser.error).toContain('本次未重复发送');
expect(loser.outboundId).toBe('out-9');
// 抢输的那一路一个字都没往外发,也没新开一行
expect(outbound.rows.size).toBe(1);
expect(outbound.rows.get('out-9')?.state).toBe('sent');
});
it('**超时 sending 的复用同样要抢**:抢不到就走「未重复发送」那一支', async () => {
const { service, accounts, outbound, imap, clock } = harness();
await accounts.save(account());
await outbound.insert(outboundRow({ createdAt: new Date(clock.now().getTime() - 6 * 60_000) }));
const [a, b] = await Promise.all([
service.send({ ...sendInput, idempotencyKey: 'k-1' }),
service.send({ ...sendInput, idempotencyKey: 'k-1' }),
]);
expect(imap.sent).toHaveLength(1);
expect([a.state, b.state].sort()).toEqual(['failed', 'sent']);
});
it('失败重发成功之后,那一行不留上一次的错误话', async () => {
const { service, accounts, outbound, imap } = harness();
await accounts.save(account());
imap.failSendWith(new Error('暂时不行'));
const first = await service.send({ ...sendInput, idempotencyKey: 'k-1' });
expect(first.state).toBe('failed');
expect(outbound.rows.get(first.outboundId)?.error).toContain('暂时不行');
imap.failSendWith(undefined);
const second = await service.send({ ...sendInput, idempotencyKey: 'k-1' });
expect(second.state).toBe('sent');
expect(outbound.rows.get(first.outboundId)?.error).toBeNull();
expect((await service.listOutbound({}))[0]?.error).toBeNull();
// 第三次同键走 resultOf(prior):既说成功又带错误的结果会让调用方按 error 分支判反
const third = await service.send({ ...sendInput, idempotencyKey: 'k-1' });
expect(third.state).toBe('sent');
expect(third.error).toBeUndefined();
expect(imap.sent).toHaveLength(1);
});
});
describe('卡死的 dispatching', () => {
const parsed: ParsedMail = {
messageId: '<m-1@acme.com>',
references: [],
from: { address: 'buyer@acme.com', name: '买家' },
to: [{ address: 'sales@we.com', name: '销售' }],
cc: [],
subject: '询价',
text: '请报价',
attachments: [],
};
it('自动路径不进(墙),人按的重新投递能接管(路)', async () => {
const { service, accounts, inbound, clock } = harness();
await accounts.save(account());
await inbound.insert(
buildInboundRecord({
id: 'in-1',
accountId: 'acc-1',
address: 'sales@we.com',
providerRef: 'uid-1',
parsed,
eml: new Uint8Array(),
receivedAt: clock.now(),
attachments: [],
now: clock.now(),
}),
);
let calls = 0;
service.subscribe('channel', async () => {
calls += 1;
});
// 进程崩在写 dispatching 与写结果之间:这条留痕永远停在 dispatching
const stuck = { mailId: 'in-1', subscriber: 'channel', state: 'dispatching' as const, error: null, finishedAt: null };
await inbound.setDelivery({ ...stuck, startedAt: new Date(clock.now().getTime() - 11 * 60_000) });
const reclaimed = await service.redeliver('in-1');
expect(reclaimed[0]?.state).toBe('done');
expect(calls).toBe(1);
// 新鲜的那条仍是墙:人按也不抢,免得两条线同时改一件事
await inbound.setDelivery({ ...stuck, startedAt: clock.now() });
const skipped = await service.redeliver('in-1');
expect(skipped[0]?.state).toBe('dispatching');
expect(calls).toBe(1);
});
});
@@ -0,0 +1,349 @@
/**
* 拉取与分发:先持久后分发、两把唯一键去重、逐封推进游标、中途失败停在最后一封成功处、
* 订阅方 skipped / failed 互不影响、自动只投一次、重新投递、租约被占 409、凭证空记错误。
*/
import { describe, expect, it } from 'vitest';
import type { MailInboundDetail } from '@contracts';
import { MailAccountDisabledError, MailPollBusyError, MailSubscriberTakenError, MailSubscriberUnknownError } from '../contract.ts';
import { REANCHOR_NOTE } from '../internal/adapter/imap-smtp-provider.ts';
import { createMailService } from '../internal/usecase/mail-service.ts';
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
import { imapCursor } from '../internal/domain/cursor.ts';
import type { InboundStore, MimeParser } from '../port.ts';
import {
account,
eml,
fakeProvider,
fixedClock,
memoryAccounts,
memoryAttachments,
memoryInbound,
memoryOutbound,
plainSecrets,
rawMessage,
seqIds,
} from './fakes.ts';
function harness(over: { perTick?: number; mime?: MimeParser; inbound?: InboundStore } = {}) {
const clock = fixedClock();
const accounts = memoryAccounts(clock);
const inbound = over.inbound ?? memoryInbound();
const attachments = memoryAttachments();
const imap = fakeProvider('imap-smtp');
const wecom = fakeProvider('wecom');
const changes: string[] = [];
const service = createMailService({
accounts,
inbound,
outbound: memoryOutbound(),
attachments,
providers: { 'imap-smtp': imap, wecom },
mime: over.mime ?? createPostalMimeParser(),
secrets: plainSecrets,
clock,
ids: seqIds('in'),
notify: { changed: (r) => void changes.push(r) },
limits: { perTick: over.perTick ?? 50 },
});
return { service, accounts, inbound, attachments, imap, wecom, clock, changes };
}
/** 解析到点名那封就炸 —— 「毒信」的最小可复现形(真实触发器是 MIME 解出二进制垃圾) */
function poisonMime(poison: string): MimeParser {
const real = createPostalMimeParser();
return {
async parse(emlBytes) {
if (new TextDecoder().decode(emlBytes).includes(poison)) throw new Error('MIME 解析炸了:invalid byte sequence');
return real.parse(emlBytes);
},
};
}
describe('拉取', () => {
it('无游标先锚定;拉到的信入库、附件入 upload、游标逐封推进、lastPolledAt 与 lastInboundAt 记下', async () => {
const h = harness();
await h.accounts.save(account());
h.imap.queue(
rawMessage('uid:v1:1', eml({ messageId: '<m1@acme.com>', subject: '一', attachment: { name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', content: 'PK' } })),
rawMessage('uid:v1:2', eml({ messageId: '<m2@acme.com>', subject: '二' })),
);
const outcome = await h.service.pollNow('acc-1');
expect(outcome).toEqual({ accountId: 'acc-1', pulled: 2, stored: 2, dispatched: 2, more: false });
const saved = h.accounts.rows.get('acc-1');
expect(saved?.cursor).toEqual(imapCursor('v1', 2));
expect(saved?.lastPolledAt).toEqual(h.clock.now());
expect(saved?.lastInboundAt).not.toBeNull();
expect(saved?.lastPollError).toBeNull();
expect(saved?.pollingUntil).toBeNull();
const list = await h.service.listInbound({});
// 两封 receivedAt 相同(夹具默认值),按插入顺序稳定排 —— 只断集合,不赌顺序
expect(list.map((m) => m.subject).sort()).toEqual(['一', '二']);
const one = list.find((m) => m.subject === '一');
expect(one?.attachments[0]?.ref).toBe('up-1');
expect(h.attachments.files.get('up-1')?.note).toBe('邮件附件:一');
expect(h.changes).toContain('inbound');
});
it('重复拉到(同 provider_ref 或同 Message-ID)不入库不分发、附件不重存', async () => {
const h = harness();
await h.accounts.save(account());
const hits: string[] = [];
h.service.subscribe('spy', async (m) => void hits.push(m.messageId));
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@acme.com>', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'x' } })));
await h.service.pollNow('acc-1');
h.imap.queue(
// 撞 providerRef 的这封**带附件**:守卫在存附件之前,所以「不重存」这句话才验得出来
// (原来两封都没附件,files.size 恒为 1,改坏实现照样绿)
rawMessage('uid:v1:1', eml({ messageId: '<m1@acme.com>', attachment: { name: 'dup.txt', contentType: 'text/plain', content: 'y' } })),
// 注:撞 **messageId**(而 providerRef 是新的)那条路,附件会在 insert 报重复之前就存下 ——
// 留下一份没人引用的孤儿。已记欠账(要根治得给 InboundStore 加一个按 messageId 查重的口),本轮不在此坐实。
rawMessage('uid:v1:9', eml({ messageId: '<m1@acme.com>' })),
);
const again = await h.service.pollNow('acc-1');
expect(again.pulled).toBe(2);
expect(again.stored).toBe(0);
expect(hits).toEqual(['<m1@acme.com>']);
expect(h.attachments.files.size).toBe(1);
});
it('每轮封顶 perTick:余下的报 more=true,下一轮接着;wecom 形整批推进游标', async () => {
const h = harness({ perTick: 2 });
await h.accounts.save(account({ id: 'w', kind: 'wecom', name: '应用', address: 'app@corp.wecom.work', secretCipher: `sealed:${JSON.stringify({ corpSecret: 'cs' })}`, settings: { corpId: 'ww1' } }));
h.wecom.queue(rawMessage('mid-1', eml({ messageId: '<1@a>' })), rawMessage('mid-2', eml({ messageId: '<2@a>' })), rawMessage('mid-3', eml({ messageId: '<3@a>' })));
const first = await h.service.pollNow('w');
expect(first).toMatchObject({ pulled: 2, stored: 2, more: true });
expect(h.accounts.rows.get('w')?.cursor?.kind).toBe('wecom');
const second = await h.service.pollNow('w');
expect(second).toMatchObject({ pulled: 1, stored: 1, more: false });
});
it('拉取抛错:第二次调用才炸(不是从没机会挪动的第一次)—— 游标停在上一轮的成功处、记 last_poll_error(脱敏)、租约还了、下一轮照常', async () => {
/*
* 这只假 provider 的 pull() 是原子的(一次要么整批给、要么整批不给),且总是把能给的一次给满 ——
* 因此单次 pollNow() 内永远只有一次真实的 pull() 调用(实测验证过)。
* 要测「中途失败、游标停在最后一封成功处」,失败就不能发生在第一次调用上(那样游标压根没机会挪动,
* "没动"是因为它从来没有过机会,不是因为它被稳稳地拦住了)。这里让第一次调用(第一轮 pollNow)
* 先成功推进游标,第二次调用(第二轮 pollNow)才炸 —— 借新加的 afterCalls 钉死"哪一次调用开始炸"。
*/
const h = harness({ perTick: 1 }); // 每轮只吃一封,好让第一轮先真的往前挪一格
await h.accounts.save(account());
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>' })), rawMessage('uid:v1:2', eml({ messageId: '<m2@a>' })));
h.imap.failPullWith(new Error('LOGIN pw-1234 rejected'), { afterCalls: 1 });
const first = await h.service.pollNow('acc-1');
expect(first).toMatchObject({ stored: 1, more: true });
expect(first.error).toBeUndefined();
expect(h.accounts.rows.get('acc-1')?.cursor).toEqual(imapCursor('v1', 1)); // 第一轮真的挪到了 uid 1
const outcome = await h.service.pollNow('acc-1');
expect(outcome.error).toBe('LOGIN *** rejected');
const saved = h.accounts.rows.get('acc-1');
// **停在最后一封成功处**(上一轮推进到的 uid 1),不是"从未挪动过"
expect(saved?.cursor).toEqual(imapCursor('v1', 1));
expect(saved?.lastPollError).toBe('LOGIN *** rejected');
expect(saved?.pollingUntil).toBeNull();
h.imap.failPullWith(undefined);
const third = await h.service.pollNow('acc-1');
expect(third.error).toBeUndefined();
expect(third.stored).toBe(1); // 第二封这才真正拉到
expect(h.accounts.rows.get('acc-1')?.lastPollError).toBeNull();
});
it('凭证空:不连 provider,记「未配置凭证」;停用 → 412;租约被占 → 409', async () => {
const h = harness();
await h.accounts.save(account({ secretCipher: null }));
const outcome = await h.service.pollNow('acc-1');
expect(outcome.error).toContain('未配置凭证');
expect(h.imap.pullCalls).toHaveLength(0);
await h.accounts.save(account({ enabled: false }));
await expect(h.service.pollNow('acc-1')).rejects.toBeInstanceOf(MailAccountDisabledError);
// 租约是**运行列**:`save()` 只写配置列(两台后端连同一个库时整行 upsert 会盖掉别人的租约),
// 所以这里按真路径占它 —— claimPolling 就是另一台机器那一下
await h.accounts.save(account());
expect(await h.accounts.claimPolling('acc-1', 60_000)).toBe(true);
await expect(h.service.pollNow('acc-1')).rejects.toBeInstanceOf(MailPollBusyError);
});
it('pollDue:到期的账号各拉一轮,一个出错不影响别的', async () => {
const h = harness();
await h.accounts.save(account({ id: 'a', name: 'a', address: 'a@we.com' }));
await h.accounts.save(account({ id: 'b', name: 'b', address: 'b@we.com', secretCipher: null }));
await h.accounts.save(account({ id: 'c', name: 'c', address: 'c@we.com', enabled: false }));
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<x@a>' })));
const outcomes = await h.service.pollDue(h.clock.now());
expect(outcomes.map((o) => o.accountId).sort()).toEqual(['a', 'b']);
expect(outcomes.find((o) => o.accountId === 'a')?.stored).toBe(1);
expect(outcomes.find((o) => o.accountId === 'b')?.error).toContain('未配置凭证');
});
});
describe('毒信不许卡死整条路(降级存档)', () => {
it('第一封解析必炸:降级行落库、游标越过它、第二封照常入库、error 点了名', async () => {
const h = harness({ mime: poisonMime('毒') });
await h.accounts.save(account());
h.imap.queue(
rawMessage('uid:v1:1', eml({ messageId: '<bad@a>', subject: '毒信一封' })),
rawMessage('uid:v1:2', eml({ messageId: '<ok@a>', subject: '好信' })),
);
const hits: string[] = [];
h.service.subscribe('channel', async (m) => void hits.push(m.subject));
const outcome = await h.service.pollNow('acc-1');
// 两封都落了库(降级的那封也算 stored),但只有解得开的那封投给了订阅方
expect(outcome).toMatchObject({ pulled: 2, stored: 2, dispatched: 1 });
expect(hits).toEqual(['好信']);
const list = await h.service.listInbound({});
const degraded = list.find((m) => m.messageId.includes('uid:v1:1'));
// 主题从原始字节里扒出来 —— 人在列表上认得出是哪一封卡住了
expect(degraded?.subject).toBe('毒信一封');
expect(degraded?.textPreview).toContain('降级存档');
expect(list.map((m) => m.subject).sort()).toEqual(['好信', '毒信一封']);
// **游标越过它**:下一轮不会再从这一封重来(否则后面所有新信永远进不来)
const saved = h.accounts.rows.get('acc-1');
expect(saved?.cursor).toEqual(imapCursor('v1', 2));
// 顺利收尾也要说实话:第几封、是谁
expect(saved?.lastPollError).toContain('第 1 封');
expect(saved?.lastPollError).toContain('uid:v1:1');
expect(saved?.lastPollError).toContain('已降级存档');
expect(outcome.error).toBe(saved?.lastPollError);
// 降级行原件仍在:人点得开、看得到原文
expect((await h.service.raw('in-1')).bytes.byteLength).toBeGreaterThan(0);
// 它没有任何投递痕 —— 正文是失败原因不是客户的话,不自动投;人可按「重新投递」
expect((await h.service.getInbound('in-1')).deliveries).toEqual([]);
});
it('骨架也落不下:停在这一封,error 点名是谁卡着(不静默跳过,那是丢信)', async () => {
const inbound = memoryInbound();
const real = inbound.insert.bind(inbound);
let refuse = true;
// 入库这一步整个不行(磁盘满 / 约束冲突 / 列型不合)—— 连最小骨架都写不下去
inbound.insert = async (record) => (refuse ? Promise.reject(new Error('入库失败:disk full')) : real(record));
const h = harness({ inbound });
await h.accounts.save(account({ cursor: imapCursor('v1', 0) }));
h.imap.queue(rawMessage('uid:v1:7', eml({ messageId: '<x@a>' })));
const outcome = await h.service.pollNow('acc-1');
expect(outcome.error).toContain('uid:v1:7');
expect(outcome.error).toContain('降级骨架也落不下');
const saved = h.accounts.rows.get('acc-1');
expect(saved?.lastPollError).toBe(outcome.error);
// 停在这一封**之前**:数据一封都没丢,人知道是谁卡着,修好了下一轮接着拉
expect(saved?.cursor).toEqual(imapCursor('v1', 0));
refuse = false;
h.imap.queue(rawMessage('uid:v1:7', eml({ messageId: '<x@a>' })));
const recovered = await h.service.pollNow('acc-1');
expect(recovered.stored).toBe(1);
expect(recovered.error).toBeUndefined();
});
});
describe('provider 说的话要落到界面上', () => {
it('UIDVALIDITY 变化重锚:这一轮不算失败,但 last_poll_error 里有那句说明', async () => {
const h = harness();
await h.accounts.save(account());
h.imap.noteWith(REANCHOR_NOTE);
h.imap.queue(rawMessage('uid:v2:1', eml({ messageId: '<m1@a>' })));
const outcome = await h.service.pollNow('acc-1');
expect(outcome.stored).toBe(1); // 拉到的照常入库
expect(outcome.error).toBe(REANCHOR_NOTE);
expect(h.accounts.rows.get('acc-1')?.lastPollError).toContain('UIDVALIDITY');
expect(h.accounts.rows.get('acc-1')?.lastPollError).toContain('期间来信未拉取');
// 说明只属于报了它的那一轮:下一轮没话说就清干净,不留一条过期的红字
h.imap.noteWith(undefined);
expect((await h.service.pollNow('acc-1')).error).toBeUndefined();
expect(h.accounts.rows.get('acc-1')?.lastPollError).toBeNull();
});
});
describe('列表与分发不搬原件', () => {
it('list / getMeta 不带 raw 字节,而 rawAvailable 仍然正确;下载原件那条路照旧拿得到', async () => {
/*
* 一封原件上限 16MB、列表上限 200 行:每次 SSE 失效重取都可能搬几百 MB 只为算 200 字预览。
* 这条断言在 store 那一层 —— 服务出参本来就不带 raw,只有真去数 store 交出来的东西才验得出。
*/
const h = harness();
await h.accounts.save(account());
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>' })));
await h.service.pollNow('acc-1');
const listed = await h.inbound.list({ limit: 10 });
expect(listed[0]?.record.raw).toBeNull();
expect(listed[0]?.record.rawAvailable).toBe(true);
const meta = await h.inbound.getMeta('in-1');
expect(meta?.record.raw).toBeNull();
expect(meta?.record.rawAvailable).toBe(true);
// 详情面的 rawAvailable 读的是投影,不是「字节在不在手上」
expect((await h.service.getInbound('in-1')).rawAvailable).toBe(true);
// 下载原件仍拿得到字节
expect((await h.service.raw('in-1')).bytes.byteLength).toBeGreaterThan(0);
});
});
describe('分发', () => {
it('订阅方收到详情与 ctx(账号 / 通道);skipped 与 failed 各自留痕,互不影响;同名注册被拒;注销后不再收', async () => {
const h = harness();
await h.accounts.save(account({ channelId: 'ch-9' }));
const seen: Array<{ mail: MailInboundDetail; channelId: string | undefined }> = [];
// 注册顺序 ok → boom → skip:出错的 boom **不在最后一个**,后面还有 skip ——
// 若 dispatch 遇错就 break,skip 会被漏投,连它那条留痕都不会存在(而不只是状态不对)
h.service.subscribe('ok', async (mail, ctx) => void seen.push({ mail, channelId: ctx.channelId }));
const off = h.service.subscribe('boom', async () => {
throw new Error('通道实例已停用:ch-9');
});
h.service.subscribe('skip', async () => ({ skipped: '与我无关' }));
expect(() => h.service.subscribe('ok', async () => undefined)).toThrow(MailSubscriberTakenError);
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>', subject: '主题' })));
await h.service.pollNow('acc-1');
expect(seen[0]?.mail.subject).toBe('主题');
expect(seen[0]?.channelId).toBe('ch-9');
const detail = await h.service.getInbound('in-1');
expect(detail.deliveries.map((d) => [d.subscriber, d.state, d.error])).toEqual([
['boom', 'failed', '通道实例已停用:ch-9'],
['ok', 'done', null],
['skip', 'skipped', '与我无关'],
]);
off();
h.imap.queue(rawMessage('uid:v1:2', eml({ messageId: '<m2@a>' })));
await h.service.pollNow('acc-1');
expect((await h.service.getInbound('in-2')).deliveries.map((d) => d.subscriber)).toEqual(['ok', 'skip']);
});
it('重新投递:指定订阅方或全部;正在 dispatching 的跳过;不认识的订阅方 404', async () => {
const h = harness();
await h.accounts.save(account());
let fail = true;
h.service.subscribe('flaky', async () => {
if (fail) throw new Error('第一次不行');
});
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>' })));
await h.service.pollNow('acc-1');
expect((await h.service.getInbound('in-1')).deliveries[0]?.state).toBe('failed');
fail = false;
const redone = await h.service.redeliver('in-1', 'flaky');
expect(redone).toEqual([expect.objectContaining({ subscriber: 'flaky', state: 'done' })]);
await expect(h.service.redeliver('in-1', 'nobody')).rejects.toBeInstanceOf(MailSubscriberUnknownError);
await h.inbound.setDelivery({ mailId: 'in-1', subscriber: 'flaky', state: 'dispatching', error: null, startedAt: h.clock.now(), finishedAt: null });
expect((await h.service.redeliver('in-1'))[0]?.state).toBe('dispatching');
});
});
describe('附件与原件', () => {
it('attachment 取回字节;超限没存的 404;raw 取回原件', async () => {
const h = harness();
await h.accounts.save(account());
h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '<m1@a>', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'hello' } })));
await h.service.pollNow('acc-1');
const file = await h.service.attachment('in-1', 0);
expect(new TextDecoder().decode(file.bytes)).toBe('hello');
expect(file.name).toBe('a.txt');
await expect(h.service.attachment('in-1', 5)).rejects.toThrow('附件没有存下来');
const raw = await h.service.raw('in-1');
expect(raw.name).toBe('m1_a.eml');
expect(new TextDecoder().decode(raw.bytes)).toContain('Subject:');
});
});
@@ -0,0 +1,109 @@
/** 发信:先落 sending 再发;厂商失败落 failed 不抛;幂等键不发两封、失败可重发;附件取自 upload;from 对账号 */
import { describe, expect, it } from 'vitest';
import { MailAccountNotFoundError, MailNoDefaultAccountError, MailSecretMissingError, MailSendInvalidError } from '../contract.ts';
import { createMailService } from '../internal/usecase/mail-service.ts';
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
import { account, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts';
function harness() {
const clock = fixedClock();
const accounts = memoryAccounts(clock);
const outbound = memoryOutbound();
const attachments = memoryAttachments();
const imap = fakeProvider('imap-smtp');
const wecom = fakeProvider('wecom');
const service = createMailService({
accounts,
inbound: memoryInbound(),
outbound,
attachments,
providers: { 'imap-smtp': imap, wecom },
mime: createPostalMimeParser(),
secrets: plainSecrets,
clock,
ids: seqIds('out'),
notify: { changed: () => undefined },
});
return { service, accounts, outbound, attachments, imap, clock };
}
const base = { to: ['b@acme.com'], cc: [], bcc: [], subject: '报价', text: '见附件', references: [], attachments: [], requestedBy: { kind: 'ops' as const } };
describe('send', () => {
it('默认账号发出:留痕 sent、Message-ID 是 <id@域>、发件人带显示名、返回 providerRef', async () => {
const h = harness();
await h.accounts.save(account());
const result = await h.service.send(base);
expect(result).toEqual({ outboundId: 'out-1', accountId: 'acc-1', messageId: '<out-1@we.com>', state: 'sent', providerRef: 'fake:1' });
expect(h.imap.sent[0]?.from).toEqual({ address: 'sales@we.com', name: '销售部' });
expect(h.imap.sent[0]?.messageId).toBe('<out-1@we.com>');
const row = h.outbound.rows.get('out-1');
expect(row?.state).toBe('sent');
expect(row?.sentAt).toEqual(h.clock.now());
expect((await h.service.listOutbound({}))[0]).toMatchObject({ id: 'out-1', state: 'sent', to: ['b@acme.com'] });
});
it('厂商失败:落 failed(原话脱敏)、不抛、返回 state=failed', async () => {
const h = harness();
await h.accounts.save(account());
h.imap.failSendWith(Object.assign(new Error('Invalid login'), { response: '535 5.7.8 pw-1234 rejected' }));
const result = await h.service.send(base);
expect(result.state).toBe('failed');
expect(result.error).toBe('Invalid login —— 535 5.7.8 *** rejected');
expect(h.outbound.rows.get('out-1')?.state).toBe('failed');
});
it('幂等键:已 sent 的直接回原结果不再发;失败的那一行复用重发', async () => {
const h = harness();
await h.accounts.save(account());
h.imap.failSendWith(new Error('暂时不行'));
const failed = await h.service.send({ ...base, idempotencyKey: 'k-1' });
expect(failed.state).toBe('failed');
h.imap.failSendWith(undefined);
const retried = await h.service.send({ ...base, idempotencyKey: 'k-1' });
expect(retried).toMatchObject({ outboundId: failed.outboundId, state: 'sent' });
const again = await h.service.send({ ...base, idempotencyKey: 'k-1' });
expect(again).toMatchObject({ outboundId: failed.outboundId, state: 'sent' });
expect(h.imap.sent).toHaveLength(1);
expect(h.outbound.rows.size).toBe(1);
});
it('入参问题抛而不落行:无正文 400、没默认账号 412、from 对不上 404、凭证空 412', async () => {
const h = harness();
await expect(h.service.send(base)).rejects.toBeInstanceOf(MailNoDefaultAccountError);
await h.accounts.save(account());
await expect(h.service.send({ ...base, text: undefined })).rejects.toBeInstanceOf(MailSendInvalidError);
await expect(h.service.send({ ...base, from: 'nobody@we.com' })).rejects.toBeInstanceOf(MailAccountNotFoundError);
await h.accounts.save(account({ secretCipher: null }));
await expect(h.service.send(base)).rejects.toBeInstanceOf(MailSecretMissingError);
expect(h.outbound.rows.size).toBe(0);
});
it('附件:uploadRef 取 upload 的字节与类型;dataBase64 直接用;两者都没有 400', async () => {
const h = harness();
await h.accounts.save(account());
const { ref } = await h.attachments.put({ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]), note: '' });
await h.service.send({
...base,
attachments: [
{ name: 'q.pdf', contentType: 'application/octet-stream', uploadRef: ref },
{ name: 'n.txt', contentType: 'text/plain', dataBase64: Buffer.from('hi').toString('base64') },
],
});
expect(h.imap.sent[0]?.attachments.map((a) => [a.name, a.contentType, a.bytes.byteLength])).toEqual([
['q.pdf', 'application/pdf', 2],
['n.txt', 'text/plain', 2],
]);
await expect(h.service.send({ ...base, attachments: [{ name: 'x', contentType: 'text/plain' }] })).rejects.toBeInstanceOf(MailSendInvalidError);
await expect(h.service.send({ ...base, attachments: [{ name: 'x', contentType: 'text/plain', uploadRef: 'up-404' }] })).rejects.toBeInstanceOf(MailSendInvalidError);
});
it('from 指定发件地址(channel 用):按地址找账号,inReplyTo / references 原样交给 provider', async () => {
const h = harness();
await h.accounts.save(account({ isDefault: false }));
const result = await h.service.send({ ...base, from: 'SALES@we.com', inReplyTo: '<m1@acme.com>', references: ['<m1@acme.com>'], requestedBy: { kind: 'channel', taskId: 't-1' } });
expect(result.accountId).toBe('acc-1');
expect(h.imap.sent[0]?.inReplyTo).toBe('<m1@acme.com>');
expect(h.imap.sent[0]?.references).toEqual(['<m1@acme.com>']);
expect(h.outbound.rows.get('out-1')?.requestedBy).toEqual({ kind: 'channel', taskId: 't-1' });
});
});
@@ -0,0 +1,302 @@
/**
* mail 存储集成测试 —— 真 PG:两把唯一键、默认账号唯一、到期挑选、**跨进程租约两连接对抢**、
* 投递 upsert、出站幂等键、bytea 原件按元素拷回来(pg 的 Buffer 挂在共享内存池上)。
*
* 这组用例**共享状态、按顺序跑**:游标那条依赖第一条落下的 acc-a,listDue / SQL-domain 同源
* 依赖前面存的那一批账号,投递与「不搬原件」两条依赖入站那条写下的 in-1 ——
* 单独用 `-t` 挑一条跑会失败,这是设计如此,不是缺陷。
* 同理:新增用例要**摆在对列表顺序有精确断言的那一条之后**(否则新行会挤进它的期望里)。
*/
import { readFileSync } from 'node:fs';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import type { Db } from '@shared/db.ts';
import type { InboundRecord, MailAccountRecord, OutboundRecord } from '../port.ts';
import { imapCursor } from '../internal/domain/cursor.ts';
import { isDue } from '../internal/domain/due.ts';
import { createInboundStore, createMailAccountStore, createOutboundStore } from '../internal/adapter/mail-store.ts';
/*
* **独占一个库**:这份测试要 drop/建 `sys.mail_*` 四张表,其中 `sys.mail_account` 是**配置表**
* (刻意不进 reset-runs)。若落在共享库上,开发者导出过 DATABASE_URL 的那一刻,
* 一次 `pnpm gates` 就会把他真实配好的邮箱账号连同密封凭证删掉。
* 所以照 route-coverage.it.test.ts 的先例:从 base URL 换出一个 `jcai_it_mail` 来,谁的库都不碰。
*/
const IT_DB = 'jcai_it_mail';
async function provisionDatabase(): Promise<{ url: string; stop(): Promise<void> } | undefined> {
const base = process.env['JCAI_TEST_DATABASE_URL'] ?? process.env['DATABASE_URL'] ?? 'postgres://jcai:jcai@localhost:54329/jcai';
try {
const admin = new Pool({ connectionString: base, max: 1, connectionTimeoutMillis: 1500 });
const exists = await admin.query(`select 1 from pg_database where datname = $1`, [IT_DB]);
if (exists.rowCount === 0) await admin.query(`create database ${IT_DB}`);
await admin.end();
return { url: base.replace(/\/[^/]*$/, `/${IT_DB}`), stop: async () => undefined };
} catch {
/* 开发实例连不上,试 Testcontainers */
}
try {
const { PostgreSqlContainer } = await import('@testcontainers/postgresql');
const started = await new PostgreSqlContainer('postgres:16-alpine').start();
return { url: started.getConnectionUri(), stop: () => started.stop().then(() => undefined) };
} catch (error) {
process.stderr.write(
`⚠️ mail store it 跳过:既连不上开发实例也起不了 Testcontainers(${error instanceof Error ? error.message : String(error)})\n`,
);
return undefined;
}
}
const database = await provisionDatabase();
let poolA: Pool | undefined;
let poolB: Pool | undefined;
let db: Db;
let dbB: Db;
const MIGRATION = new URL('../../../../../../db/migrations/0033_mail.sql', import.meta.url);
const now = new Date('2026-08-25T10:00:00Z');
function account(over: Partial<MailAccountRecord>): MailAccountRecord {
return {
id: 'acc-a', kind: 'imap-smtp', name: '甲', address: 'a@we.com', displayName: '', enabled: true, isDefault: false,
pollIntervalSec: 30, channelId: null, backfillFrom: null, settings: { folder: 'INBOX' }, secretCipher: 'v1.x.y.z',
cursor: null, lastPolledAt: null, lastPollError: null, lastInboundAt: null, pollingUntil: null, createdAt: now, updatedAt: now,
...over,
};
}
function inbound(over: Partial<InboundRecord>): InboundRecord {
return {
id: 'in-1', accountId: 'acc-a', messageId: '<m1@x>', providerRef: 'uid:7:1', inReplyTo: null, references: [],
from: { address: 'b@acme.com', name: '' }, to: [{ address: 'a@we.com', name: '' }], cc: [], subject: 's', text: 't', html: null,
sentAt: null, receivedAt: now, attachments: [], raw: new Uint8Array([1, 2, 3]), rawAvailable: true, rawNote: '', size: 3, createdAt: now,
...over,
};
}
function outbound(over: Partial<OutboundRecord>): OutboundRecord {
return {
id: 'out-1', accountId: 'acc-a', messageId: '<o1@we.com>', idempotencyKey: null, to: ['b@acme.com'], cc: [], bcc: [],
subject: 's', text: 't', html: null, inReplyTo: null, references: [], attachments: [], state: 'sending', error: null,
providerRef: null, requestedBy: { kind: 'ops' }, createdAt: now, sentAt: null,
...over,
};
}
beforeAll(async () => {
if (database === undefined) return;
poolA = new Pool({ connectionString: database.url, max: 2 });
poolB = new Pool({ connectionString: database.url, max: 2 });
// 新库(jcai_it_mail)里没有 sys schema —— 迁移文件假定它已存在(开发库由 db:migrate 建过)
await poolA.query('create schema if not exists sys');
await poolA.query('drop table if exists sys.mail_inbound_delivery, sys.mail_inbound, sys.mail_outbound, sys.mail_account cascade');
await poolA.query(readFileSync(MIGRATION, 'utf8'));
db = drizzle(poolA);
dbB = drizzle(poolB);
});
afterAll(async () => {
await poolA?.end();
await poolB?.end();
await database?.stop();
});
describe.skipIf(database === undefined)('mail store(真 PG)', () => {
it('账号:save / get / findByName / list;isDefault 只留一个', async () => {
const store = createMailAccountStore(db);
await store.save(account({ id: 'acc-a', name: '甲', isDefault: true }));
await store.save(account({ id: 'acc-b', name: '乙', address: 'b@we.com', isDefault: true }));
expect((await store.get('acc-a'))?.isDefault).toBe(false);
expect((await store.get('acc-b'))?.isDefault).toBe(true);
expect((await store.findByName('甲'))?.id).toBe('acc-a');
/*
* 排序键是 name(与 channel / llm 两个 store 同惯例:这份清单是运营面给人看的账号列表)。
* 中文名的先后依 PG 的 collation 而定 —— 本机 en_US.utf8 与 alpine 的 C 都按码点把「乙」排在「甲」前,
* 而 ICU 的中文 collation 按拼音会反过来。所以这里断言**集合**,不断言序:
* 这条用例要验的是「两个账号都回来了」,不是「谁在前」。
*/
expect((await store.list()).map((a) => a.id).sort()).toEqual(['acc-a', 'acc-b']);
});
it('游标 jsonb 存取回来还是同一形;savePoll 只改给了的列', async () => {
const store = createMailAccountStore(db);
await store.savePoll('acc-a', { cursor: imapCursor('7', 12), polledAt: now, error: null });
const got = await store.get('acc-a');
expect(got?.cursor).toEqual(imapCursor('7', 12));
expect(got?.lastPolledAt?.toISOString()).toBe(now.toISOString());
await store.savePoll('acc-a', { error: '连不上' });
expect((await store.get('acc-a'))?.cursor).toEqual(imapCursor('7', 12));
expect((await store.get('acc-a'))?.lastPollError).toBe('连不上');
await expect(store.savePoll('no-such', { error: 'x' })).resolves.toBeUndefined();
});
it('listDue:从没拉过 / 到点的 / 租约过期的算;停用 / 没到点 / 租约在手的不算', async () => {
const store = createMailAccountStore(db);
/*
* **运行列走 savePoll,不走 save** —— `save()` 只写配置列(见 accountConfigValues):
* 运营面 PUT 的同时另一台正拉着,整行 upsert 会把租约与游标一并盖回去。
* acc-a / acc-b 在前面的用例里已经存在,所以它们的 lastPolledAt 必须显式 savePoll;
* acc-c / acc-d / acc-e 是新行,运行列跟着 insert 落下即可。
*/
await store.save(account({ id: 'acc-a', name: '甲' }));
await store.savePoll('acc-a', { polledAt: new Date(now.getTime() - 60_000) });
await store.save(account({ id: 'acc-b', name: '乙', address: 'b@we.com' }));
await store.savePoll('acc-b', { polledAt: new Date(now.getTime() - 5_000) });
await store.save(account({ id: 'acc-c', name: '丙', address: 'c@we.com', enabled: false }));
await store.save(account({ id: 'acc-d', name: '丁', address: 'd@we.com', pollingUntil: new Date(now.getTime() + 60_000) }));
// 恰好到点的那一行:钉住区间比较符是 `<=` 而不是 `<`(SQL 与 domain 都该判它到期)
await store.save(account({ id: 'acc-e', name: '戊', address: 'e@we.com', lastPolledAt: new Date(now.getTime() - 30_000) }));
expect((await store.listDue(now)).map((a) => a.id).sort()).toEqual(['acc-a', 'acc-e']);
});
it('**save 只写配置列**:占着租约的账号被 save 一次,租约与游标一个字不动;要清游标显式单写那一列', async () => {
/*
* 08-24 `loop_state` 整份覆盖那一课的同款指纹:`mergeAccount` 里的运行列是**读那一刻的快照**,
* 运营面 PUT 的同时另一台正拉着 → PUT 把 polling_until 写回旧值 → 第三台抢到租约 → 两台同时拉同一信箱。
*/
const store = createMailAccountStore(db);
await store.save(account({ id: 'acc-save', name: '存', address: 's@we.com' }));
await store.savePoll('acc-save', { cursor: imapCursor('7', 42), polledAt: now, error: '上一轮连不上', lastInboundAt: now });
expect(await store.claimPolling('acc-save', 60_000)).toBe(true);
const held = await store.get('acc-save');
// 运营面 PUT:只改了显示名,而这一刻另一台正持着租约、游标刚推到 42
await store.save(account({ id: 'acc-save', name: '存', address: 's@we.com', displayName: '改过', cursor: null, pollingUntil: null }));
const after = await store.get('acc-save');
expect(after?.displayName).toBe('改过');
expect(after?.cursor).toEqual(imapCursor('7', 42));
expect(after?.pollingUntil?.toISOString()).toBe(held?.pollingUntil?.toISOString());
expect(after?.lastPolledAt?.toISOString()).toBe(now.toISOString());
expect(after?.lastPollError).toBe('上一轮连不上');
expect(after?.lastInboundAt?.toISOString()).toBe(now.toISOString());
// 收侧配置真变了那条路:用例显式单写 cursor 这一列(cursorReset 为真时)
await store.savePoll('acc-save', { cursor: null, error: null });
const reset = await store.get('acc-save');
expect(reset?.cursor).toBeNull();
expect(reset?.lastPollError).toBeNull();
// 清游标不碰租约
expect(reset?.pollingUntil?.toISOString()).toBe(held?.pollingUntil?.toISOString());
await store.releasePolling('acc-save');
});
it('**SQL 与 domain 的到期判据同源**:listDue 的返回集合 == 用 isDue 过滤 list() 的结果', async () => {
// 同一条「到期」判据两处各存一份实现(SQL 里的 listDue / domain 的 isDue)——
// 本测钉住二者不悄悄分叉:哪天 SQL 把 `<=` 写成 `<`、或漏掉租约那一段,这里立刻炸。
const store = createMailAccountStore(db);
const fromSql = (await store.listDue(now)).map((a) => a.id).sort();
const fromDomain = (await store.list())
.filter((a) => isDue(a, now))
.map((a) => a.id)
.sort();
expect(fromSql).toEqual(fromDomain);
});
it('**跨进程租约**:两条真连接对抢 20 轮,每轮恰好一个赢;还了再抢得到;过期了别人接得上', async () => {
const a = createMailAccountStore(db);
const b = createMailAccountStore(dbB);
await a.save(account({ id: 'acc-lease', name: '租', address: 'l@we.com' }));
for (let round = 0; round < 20; round += 1) {
const wins = await Promise.all([a.claimPolling('acc-lease', 60_000), b.claimPolling('acc-lease', 60_000)]);
expect(wins.filter(Boolean)).toHaveLength(1);
await a.releasePolling('acc-lease');
}
expect(await a.claimPolling('acc-lease', 60_000)).toBe(true);
expect(await b.claimPolling('acc-lease', 60_000)).toBe(false);
await poolA!.query(`update sys.mail_account set polling_until = now() - interval '1 second' where id = 'acc-lease'`);
expect(await b.claimPolling('acc-lease', 60_000)).toBe(true);
await b.releasePolling('acc-lease');
// 停用的账号抢不到
await a.save(account({ id: 'acc-lease', name: '租', address: 'l@we.com', enabled: false }));
expect(await a.claimPolling('acc-lease', 60_000)).toBe(false);
});
it('入站:两把唯一键都回 inserted:false;knownProviderRefs 只回库里有的;raw 拷回来是独立字节', async () => {
const store = createInboundStore(db);
expect(await store.insert(inbound({}))).toEqual({ inserted: true });
expect(await store.insert(inbound({ id: 'in-dup-msg', providerRef: 'uid:7:2' }))).toEqual({ inserted: false });
expect(await store.insert(inbound({ id: 'in-dup-ref', messageId: '<other@x>' }))).toEqual({ inserted: false });
expect(await store.knownProviderRefs('acc-a', ['uid:7:1', 'uid:7:99'])).toEqual(new Set(['uid:7:1']));
expect(await store.knownProviderRefs('acc-a', [])).toEqual(new Set());
const got = await store.get('in-1');
expect(got?.record.raw).toEqual(new Uint8Array([1, 2, 3]));
expect(got?.record.raw).toBeInstanceOf(Uint8Array);
expect(Buffer.isBuffer(got?.record.raw)).toBe(false);
expect(got?.record.rawAvailable).toBe(true);
});
it('投递:同一 (mail, subscriber) upsert;list 按收信时间倒序并带投递', async () => {
const store = createInboundStore(db);
await store.insert(inbound({ id: 'in-2', messageId: '<m2@x>', providerRef: 'uid:7:2', receivedAt: new Date(now.getTime() + 1000) }));
await store.setDelivery({ mailId: 'in-2', subscriber: 'channel', state: 'dispatching', error: null, startedAt: now, finishedAt: null });
await store.setDelivery({ mailId: 'in-2', subscriber: 'channel', state: 'failed', error: '通道停用', startedAt: now, finishedAt: now });
const list = await store.list({ accountId: 'acc-a', limit: 10 });
expect(list.map((x) => x.record.id)).toEqual(['in-2', 'in-1']);
expect(list[0]?.deliveries).toEqual([{ mailId: 'in-2', subscriber: 'channel', state: 'failed', error: '通道停用', startedAt: now, finishedAt: now }]);
expect(await store.list({ accountId: 'acc-zzz', limit: 10 })).toEqual([]);
expect((await store.list({ limit: 1 })).map((x) => x.record.id)).toEqual(['in-2']);
});
it('**列表与元信息读不搬原件**:raw 不取回来,而 rawAvailable 由 `raw is not null` 投影出来', async () => {
/*
* 一封原件上限 16MB、列表默认 50 行 / 上限 200:每次 SSE 失效重取都可能把几百 MB 搬进内存,
* 只为算一段 200 字预览。判「有没有原件」不必先把它扛回来。
*/
const store = createInboundStore(db);
await store.insert(inbound({ id: 'in-noraw', messageId: '<m-noraw@x>', providerRef: 'uid:7:9', raw: null, rawAvailable: false, rawNote: '原件超限未存' }));
const listed = await store.list({ accountId: 'acc-a', limit: 10 });
for (const row of listed) expect(row.record.raw).toBeNull();
expect(listed.find((r) => r.record.id === 'in-1')?.record.rawAvailable).toBe(true);
expect(listed.find((r) => r.record.id === 'in-noraw')?.record.rawAvailable).toBe(false);
const meta = await store.getMeta('in-1');
expect(meta?.record.raw).toBeNull();
expect(meta?.record.rawAvailable).toBe(true);
expect(meta?.record.subject).toBe('s'); // 元信息一样不少
expect(await store.getMeta('in-none')).toBeUndefined();
// 下载原件那条路照旧拿得到字节
expect((await store.get('in-1'))?.record.raw).toEqual(new Uint8Array([1, 2, 3]));
expect((await store.get('in-noraw'))?.record.rawAvailable).toBe(false);
});
it('出站:insert / byIdempotencyKey / setState / list', async () => {
const store = createOutboundStore(db);
await store.insert(outbound({ id: 'out-1', idempotencyKey: 'k-1' }));
await store.insert(outbound({ id: 'out-2', messageId: '<o2@we.com>' }));
expect((await store.byIdempotencyKey('k-1'))?.id).toBe('out-1');
expect(await store.byIdempotencyKey('k-none')).toBeUndefined();
await store.setState('out-1', 'sent', { providerRef: 'smtp:250 ok', sentAt: now });
await store.setState('out-2', 'failed', { error: '535 auth failed' });
const list = await store.list({ accountId: 'acc-a', limit: 10 });
expect(list.find((o) => o.id === 'out-1')).toMatchObject({ state: 'sent', providerRef: 'smtp:250 ok' });
expect(list.find((o) => o.id === 'out-2')).toMatchObject({ state: 'failed', error: '535 auth failed' });
// 同一幂等键第二次 insert 撞唯一索引 —— 用例层先查后写,这里只证明索引在
await expect(store.insert(outbound({ id: 'out-3', idempotencyKey: 'k-1' }))).rejects.toThrow();
});
it('**复用旧行要抢**:两条真连接同时抢同一行,只有一个抢得到;sent 的行谁也抢不到', async () => {
/*
* 两个后端连同一个库时,两台会同时读到同一行 `failed` / 超时 `sending` 并都判「可以复用」——
* 没有这一句条件更新,两台都发,就是两封真邮件(契约白纸黑字的「同键不发两封」当场破掉)。
*/
const a = createOutboundStore(db);
const b = createOutboundStore(dbB);
const staleBefore = new Date(now.getTime() - 5 * 60_000);
await a.insert(outbound({ id: 'out-race', messageId: '<race@we.com>', state: 'failed', error: '上一次没发出去' }));
const wins = await Promise.all([
a.claimForResend('out-race', { staleBefore, now }),
b.claimForResend('out-race', { staleBefore, now }),
]);
expect(wins.filter(Boolean)).toHaveLength(1);
const row = (await a.list({ accountId: 'acc-a', limit: 50 })).find((o) => o.id === 'out-race');
// 抢到即置 sending 并把 created_at 推到 now —— 那一行当场离开可抢的集合
expect(row?.state).toBe('sending');
expect(row?.error).toBeNull();
expect(await b.claimForResend('out-race', { staleBefore, now })).toBe(false);
// 停够了才算「上次崩在半路」:把 created_at 推到 staleBefore 之前,又抢得到
expect(await b.claimForResend('out-race', { staleBefore: new Date(now.getTime() + 60_000), now })).toBe(true);
// 已经发出去的那一行谁也抢不到
await a.setState('out-race', 'sent', { sentAt: now });
expect(await a.claimForResend('out-race', { staleBefore: new Date(now.getTime() + 60_000), now })).toBe(false);
expect(await a.claimForResend('out-none', { staleBefore, now })).toBe(false);
});
});
@@ -0,0 +1,81 @@
/** MIME 解析 —— 真 postal-mime:GBK 编码字头、群组地址、References 拆串、纯 html、附件字节 */
import { describe, expect, it } from 'vitest';
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
import { eml } from './fakes.ts';
const parser = createPostalMimeParser();
const CRLF = '\r\n';
describe('createPostalMimeParser', () => {
it('地址 / 主题 / 正文 / Message-ID / In-Reply-To / References / 日期', async () => {
const parsed = await parser.parse(
eml({
from: '"张三" <buyer@acme.com>',
to: 'sales@we.com, "Ops" <ops@we.com>',
subject: '询价 RFQ-1',
messageId: '<m1@acme.com>',
inReplyTo: '<q1@we.com>',
references: '<root@we.com> <q1@we.com>',
text: '你好,请报价',
}),
);
expect(parsed.from).toEqual({ address: 'buyer@acme.com', name: '张三' });
expect(parsed.to).toEqual([
{ address: 'sales@we.com', name: '' },
{ address: 'ops@we.com', name: 'Ops' },
]);
expect(parsed.subject).toBe('询价 RFQ-1');
expect(parsed.messageId).toBe('<m1@acme.com>');
expect(parsed.inReplyTo).toBe('<q1@we.com>');
expect(parsed.references).toEqual(['<root@we.com>', '<q1@we.com>']);
expect(parsed.text?.trim()).toBe('你好,请报价');
expect(parsed.date?.toISOString()).toBe('2026-08-25T01:00:00.000Z');
});
it('GBK 的 =?GBK?B?…?= 主题与 charset=gbk 正文解成中文(Node 自带 TextDecoder)', async () => {
const subject = Buffer.from([0xd1, 0xaf, 0xbc, 0xdb]).toString('base64'); // 询价
const body = Buffer.from([0xc4, 0xe3, 0xba, 0xc3]).toString('base64'); // 你好
const raw = [
'From: buyer@acme.com',
'To: sales@we.com',
`Subject: =?GBK?B?${subject}?=`,
'Content-Type: text/plain; charset=gbk',
'Content-Transfer-Encoding: base64',
'',
body,
'',
].join(CRLF);
const parsed = await parser.parse(new TextEncoder().encode(raw));
expect(parsed.subject).toBe('询价');
expect(parsed.text?.trim()).toBe('你好');
});
it('群组地址摊平;没有 Cc 就是空数组', async () => {
const raw = ['From: a@x.com', 'To: team: b@x.com, "C" <c@x.com>;', 'Subject: s', '', 'body', ''].join(CRLF);
const parsed = await parser.parse(new TextEncoder().encode(raw));
expect(parsed.to).toEqual([
{ address: 'b@x.com', name: '' },
{ address: 'c@x.com', name: 'C' },
]);
expect(parsed.cc).toEqual([]);
});
it('纯 html 的信 text 缺席、html 在;没有 Message-ID / From 也不抛', async () => {
const raw = ['Subject: x', 'Content-Type: text/html', '', '<b>only html</b>', ''].join(CRLF);
const parsed = await parser.parse(new TextEncoder().encode(raw));
expect(parsed.text).toBeUndefined();
expect(parsed.html).toContain('only html');
expect(parsed.messageId).toBeUndefined();
expect(parsed.from).toBeUndefined();
expect(parsed.date).toBeUndefined();
});
it('附件:文件名、类型、字节(ArrayBuffer → Uint8Array)', async () => {
const parsed = await parser.parse(eml({ attachment: { name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', content: 'PKxyz' } }));
expect(parsed.attachments).toHaveLength(1);
expect(parsed.attachments[0]?.name).toBe('bom.xlsx');
expect(parsed.attachments[0]?.contentType).toBe('application/vnd.ms-excel');
expect(parsed.attachments[0]?.bytes).toBeInstanceOf(Uint8Array);
expect(new TextDecoder().decode(parsed.attachments[0]?.bytes)).toBe('PKxyz');
});
});
@@ -0,0 +1,40 @@
/** 脱敏三形:明文、base64、URL 编码 —— nodemailer 的 response 行会把 AUTH 明文 / 密文都带出来 */
import { describe, expect, it } from 'vitest';
import { errorText, redactSecrets, secretVariants } from '../internal/domain/redact.ts';
describe('redactSecrets', () => {
it('三形都换成 ***', () => {
const secret = 'p@ss w0rd';
const text = `login p@ss w0rd failed; AUTH ${Buffer.from(secret).toString('base64')}; url ${encodeURIComponent(secret)}`;
expect(redactSecrets(text, [secret])).toBe('login *** failed; AUTH ***; url ***');
});
it('短于 4 字或空的密钥不脱(否则一封信的每个字母都成了 ***)', () => {
expect(secretVariants('')).toEqual([]);
expect(secretVariants('abc')).toEqual([]);
expect(redactSecrets('abc abc', ['abc'])).toBe('abc abc');
});
it('没命中原样返回;正则元字符当字面', () => {
expect(redactSecrets('nothing here', ['s3cret!'])).toBe('nothing here');
expect(redactSecrets('key=a.b*c', ['a.b*c'])).toBe('key=***');
});
});
describe('errorText', () => {
it('Error 取 message;nodemailer 式的 response 一并带上;别的 String()', () => {
expect(errorText(new Error('boom'))).toBe('boom');
const withResponse = Object.assign(new Error('Invalid login'), { response: '535 5.7.8 Error: authentication failed' });
expect(errorText(withResponse)).toBe('Invalid login —— 535 5.7.8 Error: authentication failed');
expect(errorText('raw')).toBe('raw');
expect(errorText(undefined)).toBe('未知错误');
});
it('**出口剥 NUL** —— 这句话的去向全是 PG 的 text 列,而它的来源常常是那封信自己的字节', () => {
/*
* 降级存档那条路把解析失败的原因写进正文与 `last_poll_error`,原因串里可能带着信里的 NUL。
* 不剥的话「记下这次失败」这一笔自己也写不进去 —— 治毒信的那道门,自己被同一个字符毒死。
*/
expect(errorText(new Error('bad\u0000byte'))).toBe('badbyte');
expect(errorText(Object.assign(new Error('parse failed'), { response: 'at \u0000 offset 12' }))).toBe('parse failed —— at offset 12');
expect(errorText('\u0000')).toBe('');
expect(errorText(new Error('clean')).includes('\u0000')).toBe(false);
});
});
@@ -0,0 +1,57 @@
/** 发信入参校验与选账号 —— 指定的必须存在且启用;不指定用唯一默认;from 必须对上账号,不静默回落 */
import { describe, expect, it } from 'vitest';
import type { MailAccountRecord } from '../internal/domain/records.ts';
import { MailAccountDisabledError, MailAccountNotFoundError, MailNoDefaultAccountError } from '../internal/domain/errors.ts';
import { messageIdFor, pickAccount, SEND_ATTACHMENT_CAP, validateSend } from '../internal/domain/send-input.ts';
const now = new Date('2026-08-25T10:00:00Z');
function account(over: Partial<MailAccountRecord>): MailAccountRecord {
return {
id: 'a', kind: 'imap-smtp', name: 'a', address: 'a@we.com', displayName: '', enabled: true, isDefault: false,
pollIntervalSec: 30, channelId: null, backfillFrom: null, settings: {}, secretCipher: 'c', cursor: null,
lastPolledAt: null, lastPollError: null, lastInboundAt: null, pollingUntil: null, createdAt: now, updatedAt: now,
...over,
};
}
const base = { to: ['x@y.com'], cc: [], bcc: [], subject: '主题', text: '正文', html: undefined, attachments: [] };
describe('validateSend', () => {
it('合法入参零问题', () => {
expect(validateSend(base, 'imap-smtp', [])).toEqual([]);
});
it('正文 text / html 至少一个;附件单个 ≤8MB、总 ≤20MB', () => {
expect(validateSend({ ...base, text: undefined }, 'imap-smtp', [])).toEqual(['正文 text / html 至少一个']);
expect(validateSend(base, 'imap-smtp', [SEND_ATTACHMENT_CAP + 1])[0]).toContain('8MB');
expect(validateSend(base, 'imap-smtp', [7_000_000, 7_000_000, 7_000_000])[0]).toContain('20MB');
});
it('wecom 按文档:正文 + 附件 ≤50MB、附件 ≤200 个', () => {
expect(validateSend(base, 'wecom', Array.from({ length: 201 }, () => 1))[0]).toContain('200');
expect(validateSend(base, 'wecom', Array.from({ length: 3 }, () => 7_000_000))).toEqual([]);
});
});
describe('pickAccount', () => {
const a = account({ id: 'a', isDefault: true });
const b = account({ id: 'b', address: 'b@we.com', enabled: false });
it('不指定 → 唯一默认;没有默认 → MailNoDefaultAccountError', () => {
expect(pickAccount([a, b], {}).id).toBe('a');
expect(() => pickAccount([b], {})).toThrow(MailNoDefaultAccountError);
});
it('指定 id:不存在 404、停用 412', () => {
expect(pickAccount([a, b], { accountId: 'a' }).id).toBe('a');
expect(() => pickAccount([a, b], { accountId: 'zz' })).toThrow(MailAccountNotFoundError);
expect(() => pickAccount([a, b], { accountId: 'b' })).toThrow(MailAccountDisabledError);
});
it('指定 from:按地址(不分大小写)对账号,对不上不回落默认', () => {
expect(pickAccount([a, b], { from: 'A@WE.com' }).id).toBe('a');
expect(() => pickAccount([a, b], { from: 'nobody@we.com' })).toThrow(MailAccountNotFoundError);
expect(() => pickAccount([a, b], { from: 'b@we.com' })).toThrow(MailAccountDisabledError);
});
});
describe('messageIdFor', () => {
it('<id@域>;地址没有域用 local', () => {
expect(messageIdFor('u-1', 'a@we.com')).toBe('<u-1@we.com>');
expect(messageIdFor('u-1', '')).toBe('<u-1@local>');
});
});
@@ -0,0 +1,256 @@
/** 企业微信邮件 API 适配器 —— 对假 fetch:token 缓存 / 过期刷新 / -1 重试 / 翻页 / 已知 mail_id 不再 read_mail / EML 编码兜底 / compose_send 载荷 */
import { describe, expect, it } from 'vitest';
import { createWecomProvider, tokenKeyOf, WecomApiError } from '../internal/adapter/wecom-provider.ts';
import type { FetchLike } from '../internal/adapter/wecom-provider.ts';
import { wecomCursor } from '../internal/domain/cursor.ts';
type Call = { url: string; body: Record<string, unknown> };
function scripted(handlers: Record<string, (body: Record<string, unknown>, nth: number) => unknown>): { fetch: FetchLike; calls: Call[] } {
const calls: Call[] = [];
const counts = new Map<string, number>();
const fetch: FetchLike = async (url, init) => {
const body = init?.body === undefined ? {} : (JSON.parse(init.body) as Record<string, unknown>);
calls.push({ url, body });
const key = Object.keys(handlers).find((k) => url.includes(k));
if (key === undefined) return { ok: false, status: 404, json: async () => ({}) };
const nth = (counts.get(key) ?? 0) + 1;
counts.set(key, nth);
return { ok: true, status: 200, json: async () => handlers[key]!(body, nth) };
};
return { fetch, calls };
}
const account = { id: 'w', kind: 'wecom' as const, address: '', displayName: '应用', settings: { corpId: 'ww1' }, secret: { corpSecret: 'cs-1' } };
const known = async (): Promise<Set<string>> => new Set<string>();
const now = new Date('2026-08-25T10:00:00Z');
const EML = 'From: a@x.com\r\nTo: b@y.com\r\nSubject: hi\r\n\r\nbody\r\n';
describe('token', () => {
it('第一次取 token 之后缓存;过期前 5 分钟重取;42001 刷一次重试', async () => {
let clock = 0;
let issued = 0;
const { fetch, calls } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: `tok-${(issued += 1)}`, expires_in: 7200 }),
'get_email_alias': (_b, nth) => (nth === 2 ? { errcode: 42001, errmsg: 'access_token expired' } : { errcode: 0, email: 'app@corp.wecom.work' }),
});
const provider = createWecomProvider({ fetch, now: () => clock });
await provider.probe(account);
await provider.probe(account);
expect(calls.filter((c) => c.url.includes('gettoken'))).toHaveLength(2); // 第二次 alias 回 42001 → 刷一次
clock = 7200_000 - 4 * 60_000; // 离过期不到 5 分钟
await provider.probe(account);
expect(calls.filter((c) => c.url.includes('gettoken'))).toHaveLength(3);
expect(calls[0]?.url).toContain('corpid=ww1');
expect(calls[0]?.url).toContain('corpsecret=cs-1');
});
it('**缓存按凭证指纹,不按 account.id**:两家公司的草稿探测都用 id:draft,各取各的 token', async () => {
/*
* 未存账号的探测在用例层一律是 `id:'draft'`(mail-service.ts 的 `stored?.id ?? 'draft'`)。
* 按 id 缓存时,探完 A 公司再探 B 公司会命中 A 的 token —— 回的是 **A 的应用邮箱地址**,
* 而屏上写着 B 已探到;凭证填错的 B 也会显示成功。
*/
const { fetch, calls } = scripted({
'/cgi-bin/gettoken': (_b, nth) => ({ errcode: 0, access_token: `tok-${nth}`, expires_in: 7200 }),
'get_email_alias': () => ({ errcode: 0, email: 'app@corp.wecom.work' }),
});
const provider = createWecomProvider({ fetch, now: () => 0 });
const corpA = { ...account, id: 'draft', settings: { corpId: 'wwAAA' }, secret: { corpSecret: 'cs-a' } };
const corpB = { ...account, id: 'draft', settings: { corpId: 'wwBBB' }, secret: { corpSecret: 'cs-b' } };
await provider.probe(corpA);
await provider.probe(corpB);
const tokenCalls = calls.filter((c) => c.url.includes('gettoken'));
expect(tokenCalls).toHaveLength(2);
expect(tokenCalls[0]?.url).toContain('corpid=wwAAA');
expect(tokenCalls[1]?.url).toContain('corpid=wwBBB');
// 第二家拿的是自己那份 token,不是第一家的
expect(calls.filter((c) => c.url.includes('get_email_alias')).map((c) => c.url)).toEqual([
expect.stringContaining('access_token=tok-1'),
expect.stringContaining('access_token=tok-2'),
]);
// 再探第一家:凭证没变,还是它那一槽,不重取
await provider.probe(corpA);
expect(calls.filter((c) => c.url.includes('gettoken'))).toHaveLength(2);
});
it('**同一账号换了 secret 就换槽**:不再拿旧 token 拉旧 corp 的信箱', async () => {
const { fetch, calls } = scripted({
'/cgi-bin/gettoken': (_b, nth) => ({ errcode: 0, access_token: `tok-${nth}`, expires_in: 7200 }),
'get_email_alias': () => ({ errcode: 0, email: 'app@corp.wecom.work' }),
});
const provider = createWecomProvider({ fetch, now: () => 0 });
await provider.probe(account);
await provider.probe(account);
expect(calls.filter((c) => c.url.includes('gettoken'))).toHaveLength(1);
// PUT 换了应用 secret,id 一个字没变 —— 旧 token 立刻作废
await provider.probe({ ...account, secret: { corpSecret: 'cs-2' } });
const tokenCalls = calls.filter((c) => c.url.includes('gettoken'));
expect(tokenCalls).toHaveLength(2);
expect(tokenCalls[1]?.url).toContain('corpsecret=cs-2');
});
it('指纹里不含明文 secret', () => {
const key = tokenKeyOf('ww1', 'cs-1');
expect(key).not.toContain('cs-1');
expect(key).toMatch(/^[0-9a-f]{64}$/);
expect(tokenKeyOf('ww1', 'cs-1')).toBe(tokenKeyOf('ww1', 'cs-1'));
expect(tokenKeyOf('ww1', 'cs-1')).not.toBe(tokenKeyOf('ww2', 'cs-1'));
// 裸拼会让这两组撞在同一个指纹上
expect(tokenKeyOf('ww1', '2cs')).not.toBe(tokenKeyOf('ww12', 'cs'));
});
it('-1 系统繁忙重试 ≤3 次;别的错误码抛 WecomApiError 带 api 名', async () => {
let busy = 2;
const { fetch } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
'get_email_alias': () => (busy-- > 0 ? { errcode: -1, errmsg: 'system busy' } : { errcode: 48002, errmsg: 'api forbidden' }),
});
const report = await createWecomProvider({ fetch }).probe(account);
expect(report.receive.ok).toBe(false);
expect(report.receive.error).toContain('get_email_alias');
expect(report.receive.error).toContain('48002');
expect(report.send).toMatchObject({ checked: false });
await expect(
createWecomProvider({ fetch: scripted({ '/cgi-bin/gettoken': () => ({ errcode: 40001, errmsg: 'invalid credential' }) }).fetch }).pull(account, wecomCursor(1), { limit: 5, now, backfillFrom: null, known }),
).rejects.toBeInstanceOf(WecomApiError);
});
it('-1 连击到边界不再重试(首次 + 3 次重试恰好 4 次)', async () => {
const { fetch, calls } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
'get_email_alias': () => ({ errcode: -1, errmsg: 'system busy' }),
});
const report = await createWecomProvider({ fetch }).probe(account);
expect(report.receive.ok).toBe(false);
expect(report.receive.error).toContain('-1');
expect(calls.filter((c) => c.url.includes('get_email_alias'))).toHaveLength(4);
});
});
describe('probe / anchor', () => {
it('探到应用邮箱地址;发信一栏 checked=false', async () => {
const { fetch } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
'get_email_alias': () => ({ errcode: 0, email: 'app@corp.wecom.work', alias_list: [] }),
});
const report = await createWecomProvider({ fetch }).probe(account);
expect(report).toMatchObject({ kind: 'wecom', address: 'app@corp.wecom.work', receive: { ok: true }, send: { checked: false } });
});
it('锚定 = 当下(或回填起点)的秒', async () => {
const provider = createWecomProvider({ fetch: scripted({}).fetch });
expect(await provider.anchor(account, null, now)).toEqual(wecomCursor(Math.floor(now.getTime() / 1000)));
expect(await provider.anchor(account, new Date('2026-08-20T00:00:00Z'), now)).toEqual(wecomCursor(Math.floor(new Date('2026-08-20T00:00:00Z').getTime() / 1000)));
});
});
describe('pull', () => {
it('窗口 = lastTime−600 到当下;翻页;库里已有的 mail_id 不再 read_mail;EML 原文与 base64 都解得出;more 反映还没翻完', async () => {
const { fetch, calls } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
'get_mail_list': (body) =>
body['cursor'] === undefined
? { errcode: 0, next_cursor: 'c2', has_more: 1, mail_list: [{ mail_id: 'm1' }, { mail_id: 'm2' }] }
: { errcode: 0, next_cursor: '', has_more: 0, mail_list: [{ mail_id: 'm3' }] },
'read_mail': (body) => ({ errcode: 0, mail_data: body['mail_id'] === 'm3' ? Buffer.from(EML).toString('base64') : EML }),
});
const provider = createWecomProvider({ fetch });
const cursor = wecomCursor(Math.floor(now.getTime() / 1000) - 1000);
const result = await provider.pull(account, cursor, { limit: 10, now, backfillFrom: null, known: async (refs) => new Set(refs.filter((r) => r === 'm2')) });
const listCall = calls.find((c) => c.url.includes('get_mail_list'))?.body;
expect(listCall).toMatchObject({ begin_time: cursor.lastTime - 600, end_time: Math.floor(now.getTime() / 1000) });
expect(result.messages.map((m) => m.providerRef)).toEqual(['m1', 'm3']);
expect(new TextDecoder().decode(result.messages[1]?.eml)).toBe(EML);
expect(calls.filter((c) => c.url.includes('read_mail')).map((c) => c.body['mail_id'])).toEqual(['m1', 'm3']);
expect(result.cursor).toEqual(wecomCursor(Math.floor(now.getTime() / 1000)));
expect(result.more).toBe(false);
});
it('单页翻完但新信多过 limit —— 游标不许推进(has_more:0 也可能被 limit 截断)', async () => {
const { fetch } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
'get_mail_list': () => ({ errcode: 0, next_cursor: '', has_more: 0, mail_list: [{ mail_id: 'm1' }, { mail_id: 'm2' }, { mail_id: 'm3' }] }),
'read_mail': () => ({ errcode: 0, mail_data: EML }),
});
const cursor = wecomCursor(1);
const result = await createWecomProvider({ fetch }).pull(account, cursor, { limit: 2, now, backfillFrom: null, known });
expect(result.messages.map((m) => m.providerRef)).toEqual(['m1', 'm2']);
expect(result.more).toBe(true);
// 页翻完了(has_more:0)不等于「这一窗的新信全吃完了」—— LIST_PAGE(100)与 opts.limit 无关,
// 最后一页仍可能被 limit 截断;截断时游标必须原样退回,否则 m3 就永久丢了(重叠窗只兜 10 分钟)。
expect(result.cursor).toEqual(cursor);
});
it('**翻页有上限**:服务端一直回 has_more:1 也不死循环 —— 到顶当本轮吃不完,游标不推进', async () => {
/*
* 单请求 30s 超时护不住这一条:每一页都是**成功返回**,超时永远不触发。
* 到顶就交出手上这些、报 more=true、游标原样退回,下一轮接着翻,一封都不丢。
*/
let pages = 0;
const { fetch } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
// 每页都说「还有」,而一封新信都不给(极端形:cursor 一直动、内容全被 known 滤掉)
'get_mail_list': () => {
pages += 1;
return { errcode: 0, next_cursor: `c${pages}`, has_more: 1, mail_list: [{ mail_id: `known-${pages}` }] };
},
});
const cursor = wecomCursor(1);
const result = await createWecomProvider({ fetch }).pull(account, cursor, {
limit: 50,
now,
backfillFrom: null,
known: async (refs) => new Set(refs), // 全都已入库
});
expect(pages).toBeLessThanOrEqual(50);
expect(pages).toBeGreaterThan(1);
expect(result.messages).toEqual([]);
expect(result.more).toBe(true);
expect(result.cursor).toEqual(cursor);
});
it('取满 limit 就停,more=true', async () => {
const { fetch } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
'get_mail_list': () => ({ errcode: 0, next_cursor: 'c2', has_more: 1, mail_list: [{ mail_id: 'm1' }, { mail_id: 'm2' }] }),
'read_mail': () => ({ errcode: 0, mail_data: EML }),
});
const cursor = wecomCursor(1);
const result = await createWecomProvider({ fetch }).pull(account, cursor, { limit: 2, now, backfillFrom: null, known });
expect(result.messages).toHaveLength(2);
expect(result.more).toBe(true);
// **截断时游标原样退回**:没读到的那几封不能被跳过去(重叠窗兜不住一次积压)
expect(result.cursor).toEqual(cursor);
});
});
describe('send', () => {
it('compose_send 载荷:emails 三组、html 优先、附件 base64;成功不回 providerRef', async () => {
const { fetch, calls } = scripted({
'/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }),
'compose_send': () => ({ errcode: 0, errmsg: 'ok' }),
});
const result = await createWecomProvider({ fetch }).send(account, {
messageId: '<x@y>',
from: { address: 'app@corp.wecom.work', name: '应用' },
to: ['a@x.com'],
cc: ['b@x.com'],
bcc: [],
subject: '报价',
text: '纯文本',
html: '<p>html</p>',
references: [],
attachments: [{ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]) }],
});
expect(result).toEqual({});
const payload = calls.find((c) => c.url.includes('compose_send'))?.body;
expect(payload).toEqual({
to: { emails: ['a@x.com'] },
cc: { emails: ['b@x.com'] },
subject: '报价',
content: '<p>html</p>',
content_type: 'html',
attachment_list: [{ file_name: 'q.pdf', content: Buffer.from([1, 2]).toString('base64') }],
});
});
});
+83
View File
@@ -0,0 +1,83 @@
/**
* module: mail —— 对外类型(编码规范 §10.1;设计稿 §5.1)。
*/
import type {
MailAccountInput,
MailAccountView,
MailDeliveryView,
MailInboundDetail,
MailInboundSummary,
MailOutboundView,
MailPollOutcome,
MailProbeReport,
MailSendInput,
MailSendResult,
} from '@contracts';
export type {
MailAccountInput,
MailAccountView,
MailDeliveryView,
MailInboundDetail,
MailInboundSummary,
MailOutboundView,
MailPollOutcome,
MailProbeReport,
MailSendInput,
MailSendResult,
};
export {
MailAccountDisabledError,
MailAccountNameTakenError,
MailAccountNotFoundError,
MailAttachmentNotFoundError,
MailInboundNotFoundError,
MailNoDefaultAccountError,
MailPollBusyError,
MailSecretMissingError,
MailSendInvalidError,
MailSubscriberTakenError,
MailSubscriberUnknownError,
} from './internal/domain/errors.ts';
/**
* 订阅方回调 —— 「注册回调读到邮件内容」的落点。
* 返回 `{skipped}` 表示这封信与我无关(留痕 skipped,不算失败);抛错留痕 failed(原话脱敏)。
* **自动只投一次,不自动重试**(设计稿 §9.3);人按「重新投递」才再来。
*/
export type InboundHandler = (
mail: MailInboundDetail,
ctx: { readonly accountId: string; readonly channelId: string | undefined },
) => Promise<void | { readonly skipped: string }>;
export type MailService = {
/* ── 账号 ── */
listAccounts(): Promise<MailAccountView[]>;
getAccount(accountId: string): Promise<MailAccountView>;
createAccount(input: MailAccountInput): Promise<MailAccountView>;
/** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */
updateAccount(accountId: string, input: MailAccountInput): Promise<MailAccountView>;
/** 留痕不删(入站 / 出站表对账号不设外键) */
deleteAccount(accountId: string): Promise<void>;
/** 已存账号用库里凭证;未存用入参里的;一个字节都不落库(wecom 探到地址的已存账号除外:回填 address) */
probe(target: { accountId: string } | { input: MailAccountInput; accountId?: string | undefined }): Promise<MailProbeReport>;
/* ── 收 ── */
/** 工作者用:到期且未被占的账号各拉一轮(账号间并行) */
pollDue(now: Date): Promise<MailPollOutcome[]>;
/** 路由用:租约被占 → MailPollBusyError;停用 → MailAccountDisabledError */
pollNow(accountId: string): Promise<MailPollOutcome>;
/** 同名重复注册 → MailSubscriberTakenError;返回注销函数 */
subscribe(name: string, handler: InboundHandler): () => void;
listInbound(query: { accountId?: string | undefined; limit?: number | undefined }): Promise<MailInboundSummary[]>;
getInbound(mailId: string): Promise<MailInboundDetail>;
attachment(mailId: string, index: number): Promise<{ name: string; contentType: string; bytes: Uint8Array }>;
/** 原件 .eml;超限没存 → MailAttachmentNotFoundError(index = -1) */
raw(mailId: string): Promise<{ name: string; bytes: Uint8Array }>;
/** 人按的钮:指定订阅方或全部;正在 dispatching 的那条跳过 */
redeliver(mailId: string, subscriber?: string): Promise<MailDeliveryView[]>;
/* ── 发 ── */
/** 先落 sending 行再经 provider 发;厂商失败落 failed 并以 state='failed' 返回,不抛 */
send(input: MailSendInput): Promise<MailSendResult>;
listOutbound(query: { accountId?: string | undefined; limit?: number | undefined }): Promise<MailOutboundView[]>;
};
+91
View File
@@ -0,0 +1,91 @@
/**
* module: mail —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
InboundHandler,
MailAccountInput,
MailAccountView,
MailDeliveryView,
MailInboundDetail,
MailInboundSummary,
MailOutboundView,
MailPollOutcome,
MailProbeReport,
MailSendInput,
MailSendResult,
MailService,
} from './contract.ts';
export {
MailAccountDisabledError,
MailAccountNameTakenError,
MailAccountNotFoundError,
MailAttachmentNotFoundError,
MailInboundNotFoundError,
MailNoDefaultAccountError,
MailPollBusyError,
MailSecretMissingError,
MailSendInvalidError,
MailSubscriberTakenError,
MailSubscriberUnknownError,
} from './contract.ts';
export type {
AttachmentStore,
ChangeNotifier,
Clock,
DeliveryRecord,
IdGenerator,
InboundRecord,
InboundStore,
MailAccountRecord,
MailAccountStore,
MailCursor,
MailProvider,
MimeParser,
OutboundMessage,
OutboundRecord,
OutboundStore,
ParsedMail,
ProviderAccount,
RawMessage,
SecretBox,
} from './port.ts';
export {
advanceImap,
cursorSummary,
imapCursor,
imapNeedsReanchor,
newUids,
parseCursor,
wecomCursor,
wecomWindow,
} from './internal/domain/cursor.ts';
export { isDue, isPolling, leaseMsFor } from './internal/domain/due.ts';
export {
ATTACHMENT_CAP,
bodyTextOf,
buildInboundRecord,
detailOf,
planAttachments,
RAW_CAP,
stripHtml,
summaryOf,
threadIdOf,
} from './internal/domain/inbound-record.ts';
export { errorText, redactSecrets, secretVariants } from './internal/domain/redact.ts';
export { messageIdFor, pickAccount, validateSend } from './internal/domain/send-input.ts';
export { canRedeliver, stateOfOutcome } from './internal/domain/delivery.ts';
export { cursorReset, mergeAccount, secretOf, viewOf } from './internal/domain/account-view.ts';
export { createInboundStore, createMailAccountStore, createOutboundStore } from './internal/adapter/mail-store.ts';
export { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './internal/adapter/mail-schema.ts';
export { createPostalMimeParser } from './internal/adapter/postal-mime-parser.ts';
export type { MailServiceDeps } from './internal/usecase/mail-service.ts';
export { createMailService } from './internal/usecase/mail-service.ts';
export { createImapSmtpProvider } from './internal/adapter/imap-smtp-provider.ts';
export type { FetchLike } from './internal/adapter/wecom-provider.ts';
export { createWecomProvider, WecomApiError } from './internal/adapter/wecom-provider.ts';
@@ -0,0 +1,216 @@
/**
* 标准协议适配器 —— imapflow 收、nodemailer 发(外界;设计稿 §5.5;官方帮助 open.work.weixin.qq.com/help2/pc/19886)。
*
* 腾讯企业邮箱:imap.exmail.qq.com:993 / smtp.exmail.qq.com:465(SSL);账号 = 完整邮箱地址;
* 密码 = 邮箱密码,开了安全登录 / 微信绑定则是**客户端专用密码**。
* 只读打开文件夹 —— 轮询不动人的信箱(不改已读标记)。
* IMAP 的 `n:*` 在 n 大于最大 UID 时仍回最大那一封:UID 列表再按 `newUids` 过滤一遍。
*/
import { ImapFlow } from 'imapflow';
import nodemailer from 'nodemailer';
import type Mail from 'nodemailer/lib/mailer';
import { imapSmtpSettingsSchema } from '@contracts';
import type { ImapSmtpSettings, MailProbeReport } from '@contracts';
import { imapCursor, newUids } from '../domain/cursor.ts';
import type { MailCursor } from '../domain/cursor.ts';
import { errorText } from '../domain/redact.ts';
import type { MailProvider, OutboundMessage, ProviderAccount, RawMessage } from '../../port.ts';
const CONNECT_TIMEOUT_MS = 30_000;
const SOCKET_TIMEOUT_MS = 60_000;
/** UIDVALIDITY 变化时这一轮带回的说明 —— 落进 `last_poll_error`(测试对着这个常量断言) */
export const REANCHOR_NOTE = '信箱 UIDVALIDITY 变化(重建 / 迁移),已重锚在当下;期间来信未拉取';
export function imapSettingsOf(account: ProviderAccount): { settings: ImapSmtpSettings; login: string; password: string } {
const settings = imapSmtpSettingsSchema.parse(account.settings);
const password = account.secret['password'];
if (password === undefined || password === '') throw new Error('缺客户端专用密码(邮箱密码 / 授权码)');
return { settings, login: settings.login ?? account.address, password };
}
export function internalDateOf(value: Date | string | undefined, fallback: Date): Date {
if (value instanceof Date) return value;
if (typeof value === 'string') {
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) return parsed;
}
return fallback;
}
/** fetch 的到达顺序不保证升序 —— 按 UID 排好,cursorAfter 就是各自的 UID(逐封推进的依据) */
export function orderFetched(
uidValidity: string,
rows: ReadonlyArray<{ uid: number; source: Buffer | undefined; internalDate: Date | string | undefined }>,
now: Date,
): RawMessage[] {
return rows
.filter((r): r is { uid: number; source: Buffer; internalDate: Date | string | undefined } => r.source !== undefined)
.sort((a, b) => a.uid - b.uid)
.map((r) => ({
providerRef: `${uidValidity}:${r.uid}`,
eml: Uint8Array.from(r.source),
receivedAt: internalDateOf(r.internalDate, now),
cursorAfter: imapCursor(uidValidity, r.uid),
}));
}
export function mailOptionsOf(message: OutboundMessage): Mail.Options {
return {
messageId: message.messageId,
from: { name: message.from.name, address: message.from.address },
to: [...message.to],
cc: [...message.cc],
bcc: [...message.bcc],
subject: message.subject,
...(message.text === undefined ? {} : { text: message.text }),
...(message.html === undefined ? {} : { html: message.html }),
...(message.inReplyTo === undefined ? {} : { inReplyTo: message.inReplyTo }),
references: [...message.references],
attachments: message.attachments.map((a) => ({ filename: a.name, content: Buffer.from(a.bytes), contentType: a.contentType })),
};
}
/**
* 回填锚点要的是**最小的那个 UID**。
*
* `Math.min(...uids)` 在十万级 UID 的信箱上会**栈溢出**(整个数组铺成实参),
* 而回填正是「一次搜出一大把」的那条路。reduce 不铺参数,搜多少都稳。
* 空结果回 `fallback`(uidNext:什么都没有就锚在下一封之前)。
*/
export function lowestUid(uids: readonly number[], fallback: number): number {
return uids.reduce((low, uid) => (uid < low ? uid : low), fallback);
}
function transporterOf(account: ProviderAccount) {
const { settings, login, password } = imapSettingsOf(account);
return nodemailer.createTransport({
host: settings.smtpHost,
port: settings.smtpPort,
secure: settings.smtpTls,
requireTLS: !settings.smtpTls,
auth: { user: login, pass: password },
connectionTimeout: CONNECT_TIMEOUT_MS,
greetingTimeout: CONNECT_TIMEOUT_MS,
socketTimeout: SOCKET_TIMEOUT_MS,
});
}
async function withImap<T>(account: ProviderAccount, fn: (client: ImapFlow, folder: string) => Promise<T>): Promise<T> {
const { settings, login, password } = imapSettingsOf(account);
const client = new ImapFlow({
host: settings.imapHost,
port: settings.imapPort,
secure: settings.imapTls,
auth: { user: login, pass: password },
logger: false,
connectionTimeout: CONNECT_TIMEOUT_MS,
socketTimeout: SOCKET_TIMEOUT_MS,
});
await client.connect();
try {
return await fn(client, settings.folder);
} finally {
await client.logout().catch(() => client.close());
}
}
function mailboxOf(client: ImapFlow): { uidValidity: string; uidNext: number } {
const box = client.mailbox;
if (box === false) throw new Error('文件夹未打开');
return { uidValidity: String(box.uidValidity), uidNext: box.uidNext };
}
async function timed(run: () => Promise<void>): Promise<{ ok: true; latencyMs: number } | { ok: false; error: string }> {
const started = Date.now();
try {
await run();
return { ok: true, latencyMs: Date.now() - started };
} catch (error) {
return { ok: false, error: errorText(error) };
}
}
export function createImapSmtpProvider(): MailProvider {
return {
kind: 'imap-smtp',
async probe(account): Promise<MailProbeReport> {
const receive = await timed(() =>
withImap(account, async (client, folder) => {
const lock = await client.getMailboxLock(folder, { readOnly: true });
lock.release();
}),
);
const send = await timed(async () => {
const transporter = transporterOf(account);
try {
await transporter.verify();
} finally {
transporter.close();
}
});
return {
kind: 'imap-smtp',
address: account.address,
receive: receive.ok ? { ok: true, latencyMs: receive.latencyMs } : { ok: false, error: receive.error },
send: send.ok ? { checked: true, ok: true } : { checked: true, ok: false, error: send.error },
};
},
async anchor(account, backfillFrom): Promise<MailCursor> {
return withImap(account, async (client, folder) => {
const lock = await client.getMailboxLock(folder, { readOnly: true });
try {
const box = mailboxOf(client);
if (backfillFrom === null) return imapCursor(box.uidValidity, box.uidNext - 1);
const uids = await client.search({ since: backfillFrom }, { uid: true });
const first = lowestUid(uids === false ? [] : uids, box.uidNext);
return imapCursor(box.uidValidity, first - 1);
} finally {
lock.release();
}
});
},
async pull(account, cursor, opts) {
return withImap(account, async (client, folder) => {
const lock = await client.getMailboxLock(folder, { readOnly: true });
try {
const box = mailboxOf(client);
if (cursor.kind !== 'imap' || cursor.uidValidity !== box.uidValidity) {
/*
* UIDVALIDITY 变了(信箱重建 / 迁移):旧 UID 全作废,重锚在当下、不回填;万一重复由唯一键挡。
* **重锚要出声**:上一次游标到此刻之间的来信从此拉不到了,而账号页若一片绿,
* 人只会以为「这几天没来信」。这句话经用例落进 `last_poll_error`(设计稿 §5.5)。
*/
return { messages: [], cursor: imapCursor(box.uidValidity, box.uidNext - 1), more: false, note: REANCHOR_NOTE };
}
const found = await client.search({ uid: `${cursor.lastUid + 1}:*` }, { uid: true });
const fresh = newUids(found === false ? [] : found, cursor.lastUid);
const wanted = fresh.slice(0, opts.limit);
const rows: Array<{ uid: number; source: Buffer | undefined; internalDate: Date | string | undefined }> = [];
if (wanted.length > 0) {
for await (const msg of client.fetch(wanted, { uid: true, source: true, internalDate: true }, { uid: true })) {
rows.push({ uid: msg.uid, source: msg.source, internalDate: msg.internalDate });
}
}
const messages = orderFetched(box.uidValidity, rows, opts.now);
return { messages, cursor: messages.at(-1)?.cursorAfter ?? cursor, more: fresh.length > wanted.length };
} finally {
lock.release();
}
});
},
async send(account, message) {
const transporter = transporterOf(account);
try {
const info = await transporter.sendMail(mailOptionsOf(message));
return { providerRef: info.response };
} finally {
transporter.close();
}
},
};
}
@@ -0,0 +1,95 @@
/**
* mail 的 sys schema 表(编码规范 §7 / ADR-002)。
* 表定义单独成文件:drizzle.config.ts 以 `*-schema.ts` glob 收集;迁移手写在 db/migrations/0033_mail.sql。
*/
import { boolean, customType, integer, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
import type { MailAddress } from '@contracts';
import { sys } from '@shared/db.ts';
/** drizzle 没有 bytea 内置类型;pg 驱动两头都是 Buffer */
const bytea = customType<{ data: Buffer; driverData: Buffer }>({
dataType() {
return 'bytea';
},
});
export const mailAccountTable = sys.table('mail_account', {
id: text('id').primaryKey(),
name: text('name').notNull(),
kind: text('kind').notNull(),
address: text('address').notNull().default(''),
displayName: text('display_name').notNull().default(''),
enabled: boolean('enabled').notNull().default(true),
isDefault: boolean('is_default').notNull().default(false),
pollIntervalSec: integer('poll_interval_sec').notNull().default(30),
channelId: text('channel_id'),
backfillFrom: timestamp('backfill_from', { withTimezone: true }),
settings: jsonb('settings').$type<Record<string, unknown>>().notNull().default({}),
/** AES-256-GCM 密文;永不出接口 */
secretCipher: text('secret_cipher'),
cursor: jsonb('cursor'),
lastPolledAt: timestamp('last_polled_at', { withTimezone: true }),
lastPollError: text('last_poll_error'),
lastInboundAt: timestamp('last_inbound_at', { withTimezone: true }),
/** 跨进程租约 —— 与 sys.task.advancing_until 同款 */
pollingUntil: timestamp('polling_until', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const mailInboundTable = sys.table('mail_inbound', {
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
messageId: text('message_id').notNull(),
providerRef: text('provider_ref').notNull(),
inReplyTo: text('in_reply_to'),
referenceIds: jsonb('reference_ids').$type<string[]>().notNull().default([]),
fromAddr: text('from_addr').notNull().default(''),
fromName: text('from_name').notNull().default(''),
toList: jsonb('to_list').$type<MailAddress[]>().notNull().default([]),
ccList: jsonb('cc_list').$type<MailAddress[]>().notNull().default([]),
subject: text('subject').notNull().default(''),
textBody: text('text_body'),
htmlBody: text('html_body'),
sentAt: timestamp('sent_at', { withTimezone: true }),
receivedAt: timestamp('received_at', { withTimezone: true }).notNull(),
attachments: jsonb('attachments')
.$type<Array<{ index: number; name: string; contentType: string; size: number; ref: string | null; note: string }>>()
.notNull()
.default([]),
raw: bytea('raw'),
rawNote: text('raw_note').notNull().default(''),
size: integer('size').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export const mailInboundDeliveryTable = sys.table('mail_inbound_delivery', {
mailId: text('mail_id').notNull(),
subscriber: text('subscriber').notNull(),
state: text('state').notNull(),
error: text('error'),
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
finishedAt: timestamp('finished_at', { withTimezone: true }),
});
export const mailOutboundTable = sys.table('mail_outbound', {
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
messageId: text('message_id').notNull(),
idempotencyKey: text('idempotency_key'),
toList: jsonb('to_list').$type<string[]>().notNull(),
ccList: jsonb('cc_list').$type<string[]>().notNull().default([]),
bccList: jsonb('bcc_list').$type<string[]>().notNull().default([]),
subject: text('subject').notNull(),
textBody: text('text_body'),
htmlBody: text('html_body'),
inReplyTo: text('in_reply_to'),
referenceIds: jsonb('reference_ids').$type<string[]>().notNull().default([]),
attachments: jsonb('attachments').$type<Array<{ name: string; contentType: string; size: number; ref: string | null }>>().notNull().default([]),
state: text('state').notNull(),
error: text('error'),
providerRef: text('provider_ref'),
requestedBy: jsonb('requested_by').$type<Record<string, unknown>>().notNull().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
sentAt: timestamp('sent_at', { withTimezone: true }),
});
@@ -0,0 +1,466 @@
/**
* mail 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
* 租约那一句用原生 SQL:判与占必须在同一条语句里(设计稿 §9.1)。
*/
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
import { mailAccountKindSchema, mailDeliveryStateSchema, mailOutboundStateSchema } from '@contracts';
import type { Db } from '@shared/db.ts';
import type {
DeliveryRecord,
InboundRecord,
InboundStore,
MailAccountRecord,
MailAccountStore,
OutboundRecord,
OutboundStore,
} from '../../port.ts';
import { parseCursor } from '../domain/cursor.ts';
import { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './mail-schema.ts';
function rowsOf(result: unknown): unknown[] {
if (Array.isArray(result)) return result;
const rows = (result as { rows?: unknown }).rows;
return Array.isArray(rows) ? rows : [];
}
/* ── 账号 ─────────────────────────────────────────────────────────────── */
type AccountRow = typeof mailAccountTable.$inferSelect;
function toAccount(row: AccountRow): MailAccountRecord {
return {
id: row.id,
kind: mailAccountKindSchema.parse(row.kind),
name: row.name,
address: row.address,
displayName: row.displayName,
enabled: row.enabled,
isDefault: row.isDefault,
pollIntervalSec: row.pollIntervalSec,
channelId: row.channelId,
backfillFrom: row.backfillFrom,
settings: row.settings,
secretCipher: row.secretCipher,
cursor: parseCursor(row.cursor),
lastPolledAt: row.lastPolledAt,
lastPollError: row.lastPollError,
lastInboundAt: row.lastInboundAt,
pollingUntil: row.pollingUntil,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
/**
* **配置列** —— 人在运营面填的那些,`save()` 写的就是这一组。
* 运行列(cursor / last_polled_at / last_poll_error / last_inbound_at / polling_until)不在里面:
* `mergeAccount` 里的运行列是**读那一刻的快照**,整行 upsert 会把另一台正持着的租约与刚推进的游标
* 一并盖回去 —— 第三台随即抢到租约,两台同时拉同一个信箱(08-24 `loop_state` 整份覆盖那一课的同款指纹)。
*/
function accountConfigValues(record: MailAccountRecord) {
return {
name: record.name,
kind: record.kind,
address: record.address,
displayName: record.displayName,
enabled: record.enabled,
isDefault: record.isDefault,
pollIntervalSec: record.pollIntervalSec,
channelId: record.channelId,
backfillFrom: record.backfillFrom,
settings: record.settings,
secretCipher: record.secretCipher,
updatedAt: record.updatedAt,
};
}
/** 新行才带得上运行列(那时它们本就是空的);已存在的行只更新配置列 */
function accountValues(record: MailAccountRecord): typeof mailAccountTable.$inferInsert {
return {
id: record.id,
...accountConfigValues(record),
cursor: record.cursor,
lastPolledAt: record.lastPolledAt,
lastPollError: record.lastPollError,
lastInboundAt: record.lastInboundAt,
pollingUntil: record.pollingUntil,
createdAt: record.createdAt,
};
}
export function createMailAccountStore(db: Db): MailAccountStore {
return {
async list() {
const rows = await db.select().from(mailAccountTable).orderBy(mailAccountTable.name);
return rows.map(toAccount);
},
async get(id) {
const rows = await db.select().from(mailAccountTable).where(eq(mailAccountTable.id, id)).limit(1);
return rows[0] === undefined ? undefined : toAccount(rows[0]);
},
async findByName(name) {
const rows = await db.select().from(mailAccountTable).where(eq(mailAccountTable.name, name)).limit(1);
return rows[0] === undefined ? undefined : toAccount(rows[0]);
},
async save(record) {
await db.transaction(async (tx) => {
// 默认账号只留一个:先把别的放下,库上的部分唯一索引兜底
if (record.isDefault) {
await tx
.update(mailAccountTable)
.set({ isDefault: false })
.where(and(eq(mailAccountTable.isDefault, true), sql`${mailAccountTable.id} <> ${record.id}`));
}
await tx
.insert(mailAccountTable)
.values(accountValues(record))
// **只写配置列**:运行列归 savePoll / claimPolling / releasePolling(见 accountConfigValues)
.onConflictDoUpdate({ target: mailAccountTable.id, set: accountConfigValues(record) });
});
},
async remove(id) {
await db.delete(mailAccountTable).where(eq(mailAccountTable.id, id));
},
async listDue(now) {
const rows = await db
.select()
.from(mailAccountTable)
.where(
sql`${mailAccountTable.enabled}
and (${mailAccountTable.lastPolledAt} is null
or ${mailAccountTable.lastPolledAt} + (${mailAccountTable.pollIntervalSec} || ' seconds')::interval <= ${now})
and (${mailAccountTable.pollingUntil} is null or ${mailAccountTable.pollingUntil} < ${now})`,
)
.orderBy(mailAccountTable.name);
return rows.map(toAccount);
},
async claimPolling(id, leaseMs) {
const result = await db.execute(sql`
update sys.mail_account
set polling_until = now() + (${leaseMs} || ' milliseconds')::interval
where id = ${id} and enabled
and (polling_until is null or polling_until < now())
returning id`);
return rowsOf(result).length > 0;
},
async releasePolling(id) {
// 还锁失败不抛:租约会过期,这一轮的成果不该被还锁连累掉
await db.execute(sql`update sys.mail_account set polling_until = null where id = ${id}`).catch(() => undefined);
},
async savePoll(id, patch) {
const set: Partial<typeof mailAccountTable.$inferInsert> = {};
// `cursor: null` 是显式清游标(改了收侧配置要重锚);`undefined` 才是「这一次不动它」
if (patch.cursor !== undefined) set.cursor = patch.cursor;
if (patch.polledAt !== undefined) set.lastPolledAt = patch.polledAt;
if (patch.error !== undefined) set.lastPollError = patch.error;
if (patch.lastInboundAt !== undefined) set.lastInboundAt = patch.lastInboundAt;
if (patch.address !== undefined) set.address = patch.address;
if (Object.keys(set).length === 0) return;
await db.update(mailAccountTable).set(set).where(eq(mailAccountTable.id, id));
},
};
}
/* ── 入站 ─────────────────────────────────────────────────────────────── */
type InboundRow = typeof mailInboundTable.$inferSelect;
type DeliveryRow = typeof mailInboundDeliveryTable.$inferSelect;
/**
* **只要元信息的那几列** —— 唯独不选 `raw`。
*
* 一封原件上限 16MB,列表默认 50 行、上限 200:每次 SSE 失效重取都可能把几百 MB 搬进内存,
* 只为算一段 200 字的预览。`rawAvailable` 让库自己投影(`raw is not null`),
* 不靠把字节取回来再判 —— 判「有没有」不必先把它扛回来。
*/
const inboundMetaColumns = {
id: mailInboundTable.id,
accountId: mailInboundTable.accountId,
messageId: mailInboundTable.messageId,
providerRef: mailInboundTable.providerRef,
inReplyTo: mailInboundTable.inReplyTo,
referenceIds: mailInboundTable.referenceIds,
fromAddr: mailInboundTable.fromAddr,
fromName: mailInboundTable.fromName,
toList: mailInboundTable.toList,
ccList: mailInboundTable.ccList,
subject: mailInboundTable.subject,
textBody: mailInboundTable.textBody,
htmlBody: mailInboundTable.htmlBody,
sentAt: mailInboundTable.sentAt,
receivedAt: mailInboundTable.receivedAt,
attachments: mailInboundTable.attachments,
rawAvailable: sql<boolean>`${mailInboundTable.raw} is not null`,
rawNote: mailInboundTable.rawNote,
size: mailInboundTable.size,
createdAt: mailInboundTable.createdAt,
};
type InboundMetaRow = { [K in keyof typeof inboundMetaColumns]: K extends 'rawAvailable' ? boolean : InboundRow[K & keyof InboundRow] };
function toInboundMeta(row: InboundMetaRow): InboundRecord {
return {
id: row.id,
accountId: row.accountId,
messageId: row.messageId,
providerRef: row.providerRef,
inReplyTo: row.inReplyTo,
references: row.referenceIds,
from: { address: row.fromAddr, name: row.fromName },
to: row.toList,
cc: row.ccList,
subject: row.subject,
text: row.textBody,
html: row.htmlBody,
sentAt: row.sentAt,
receivedAt: row.receivedAt,
attachments: row.attachments,
// 没取字节:`raw` 恒 null,「有没有原件」问 rawAvailable
raw: null,
rawAvailable: row.rawAvailable,
rawNote: row.rawNote,
size: row.size,
createdAt: row.createdAt,
};
}
function toInbound(row: InboundRow): InboundRecord {
return {
id: row.id,
accountId: row.accountId,
messageId: row.messageId,
providerRef: row.providerRef,
inReplyTo: row.inReplyTo,
references: row.referenceIds,
from: { address: row.fromAddr, name: row.fromName },
to: row.toList,
cc: row.ccList,
subject: row.subject,
text: row.textBody,
html: row.htmlBody,
sentAt: row.sentAt,
receivedAt: row.receivedAt,
attachments: row.attachments,
/*
* pg 取回来的 Buffer 挂在共享的 64KB 内存池上,`.buffer` 是视图不是拷贝 ——
* 三份资料下载下来都是 65536 字节的前科在案(2026-08-23)。按元素拷。
*/
raw: row.raw === null ? null : Uint8Array.from(row.raw),
rawAvailable: row.raw !== null,
rawNote: row.rawNote,
size: row.size,
createdAt: row.createdAt,
};
}
function toDelivery(row: DeliveryRow): DeliveryRecord {
return {
mailId: row.mailId,
subscriber: row.subscriber,
state: mailDeliveryStateSchema.parse(row.state),
error: row.error,
startedAt: row.startedAt,
finishedAt: row.finishedAt,
};
}
export function createInboundStore(db: Db): InboundStore {
async function deliveriesOf(mailIds: readonly string[]): Promise<Map<string, DeliveryRecord[]>> {
const out = new Map<string, DeliveryRecord[]>();
if (mailIds.length === 0) return out;
const rows = await db
.select()
.from(mailInboundDeliveryTable)
.where(inArray(mailInboundDeliveryTable.mailId, [...mailIds]))
.orderBy(mailInboundDeliveryTable.subscriber);
for (const row of rows) {
const list = out.get(row.mailId) ?? [];
list.push(toDelivery(row));
out.set(row.mailId, list);
}
return out;
}
return {
async insert(record) {
const rows = await db
.insert(mailInboundTable)
.values({
id: record.id,
accountId: record.accountId,
messageId: record.messageId,
providerRef: record.providerRef,
inReplyTo: record.inReplyTo,
referenceIds: [...record.references],
fromAddr: record.from.address,
fromName: record.from.name,
toList: [...record.to],
ccList: [...record.cc],
subject: record.subject,
textBody: record.text,
htmlBody: record.html,
sentAt: record.sentAt,
receivedAt: record.receivedAt,
attachments: [...record.attachments],
raw: record.raw === null ? null : Buffer.from(record.raw),
rawNote: record.rawNote,
size: record.size,
createdAt: record.createdAt,
})
.onConflictDoNothing()
.returning({ id: mailInboundTable.id });
return { inserted: rows.length > 0 };
},
async knownProviderRefs(accountId, refs) {
if (refs.length === 0) return new Set();
const rows = await db
.select({ providerRef: mailInboundTable.providerRef })
.from(mailInboundTable)
.where(and(eq(mailInboundTable.accountId, accountId), inArray(mailInboundTable.providerRef, [...refs])));
return new Set(rows.map((r) => r.providerRef));
},
async list(query) {
const rows = await db
.select(inboundMetaColumns)
.from(mailInboundTable)
.where(query.accountId === undefined ? undefined : eq(mailInboundTable.accountId, query.accountId))
.orderBy(desc(mailInboundTable.receivedAt), desc(mailInboundTable.createdAt))
.limit(query.limit);
const deliveries = await deliveriesOf(rows.map((r) => r.id));
return rows.map((row) => ({ record: toInboundMeta(row), deliveries: deliveries.get(row.id) ?? [] }));
},
async getMeta(mailId) {
const rows = await db.select(inboundMetaColumns).from(mailInboundTable).where(eq(mailInboundTable.id, mailId)).limit(1);
const row = rows[0];
if (row === undefined) return undefined;
const deliveries = await deliveriesOf([mailId]);
return { record: toInboundMeta(row), deliveries: deliveries.get(mailId) ?? [] };
},
async get(mailId) {
const rows = await db.select().from(mailInboundTable).where(eq(mailInboundTable.id, mailId)).limit(1);
const row = rows[0];
if (row === undefined) return undefined;
const deliveries = await deliveriesOf([mailId]);
return { record: toInbound(row), deliveries: deliveries.get(mailId) ?? [] };
},
async setDelivery(delivery) {
const values = {
mailId: delivery.mailId,
subscriber: delivery.subscriber,
state: delivery.state,
error: delivery.error,
startedAt: delivery.startedAt,
finishedAt: delivery.finishedAt,
};
await db
.insert(mailInboundDeliveryTable)
.values(values)
.onConflictDoUpdate({
target: [mailInboundDeliveryTable.mailId, mailInboundDeliveryTable.subscriber],
set: { state: values.state, error: values.error, startedAt: values.startedAt, finishedAt: values.finishedAt },
});
},
};
}
/* ── 出站 ─────────────────────────────────────────────────────────────── */
type OutboundRow = typeof mailOutboundTable.$inferSelect;
function toOutbound(row: OutboundRow): OutboundRecord {
return {
id: row.id,
accountId: row.accountId,
messageId: row.messageId,
idempotencyKey: row.idempotencyKey,
to: row.toList,
cc: row.ccList,
bcc: row.bccList,
subject: row.subject,
text: row.textBody,
html: row.htmlBody,
inReplyTo: row.inReplyTo,
references: row.referenceIds,
attachments: row.attachments,
state: mailOutboundStateSchema.parse(row.state),
error: row.error,
providerRef: row.providerRef,
requestedBy: row.requestedBy,
createdAt: row.createdAt,
sentAt: row.sentAt,
};
}
export function createOutboundStore(db: Db): OutboundStore {
return {
async insert(record) {
await db.insert(mailOutboundTable).values({
id: record.id,
accountId: record.accountId,
messageId: record.messageId,
idempotencyKey: record.idempotencyKey,
toList: [...record.to],
ccList: [...record.cc],
bccList: [...record.bcc],
subject: record.subject,
textBody: record.text,
htmlBody: record.html,
inReplyTo: record.inReplyTo,
referenceIds: [...record.references],
attachments: [...record.attachments],
state: record.state,
error: record.error,
providerRef: record.providerRef,
requestedBy: record.requestedBy,
createdAt: record.createdAt,
sentAt: record.sentAt,
});
},
async byIdempotencyKey(key) {
const rows = await db.select().from(mailOutboundTable).where(eq(mailOutboundTable.idempotencyKey, key)).limit(1);
return rows[0] === undefined ? undefined : toOutbound(rows[0]);
},
async claimForResend(id, opts) {
/*
* **抢到才发**(设计稿 §5.4 / 契约「同键不发两封」)。条件更新一句话说完三件事:
* ①`failed` 的行可以抢;②`sending` 的行只有停够了 `staleBefore` 才算「上次崩在半路」;
* ③抢到就置 `sending` 并把 `created_at` 推到 now —— 那一行**当场离开可抢的集合**,
* 第二个人的同一句更新影响 0 行,回 false 走「本次未重复发送」那一支。
* 两个后端连同一个库时,这一句是「两台同时重放同一次放行」与「两封真邮件」之间唯一的墙。
*/
const result = await db.execute(sql`
update sys.mail_outbound
set state = 'sending', created_at = ${opts.now}, error = null
where id = ${id}
and (state = 'failed' or (state = 'sending' and created_at <= ${opts.staleBefore}))
returning id`);
return rowsOf(result).length > 0;
},
async setState(id, state, patch) {
await db
.update(mailOutboundTable)
.set({
state,
/*
* **发成功的那一行不留上一次的错误话**:同一行可能先 failed、再被幂等键复用重发成功。
* 不清的话,`listOutbound` 会摆出「state: sent 而 error: 上次那句」,
* `resultOf` 也会回出一个既说成功又带错误的结果 —— 调用方按 error 分支就判反了。
*/
...(state === 'sent' ? { error: null } : {}),
...(patch.error === undefined ? {} : { error: patch.error }),
...(patch.providerRef === undefined ? {} : { providerRef: patch.providerRef }),
...(patch.sentAt === undefined ? {} : { sentAt: patch.sentAt }),
})
.where(eq(mailOutboundTable.id, id));
},
async list(query) {
const rows = await db
.select()
.from(mailOutboundTable)
.where(query.accountId === undefined ? undefined : eq(mailOutboundTable.accountId, query.accountId))
.orderBy(desc(mailOutboundTable.createdAt))
.limit(query.limit);
return rows.map(toOutbound);
},
};
}
@@ -0,0 +1,63 @@
/**
* MIME 解析 —— postal-mime(零依赖;GBK / GB18030 走 Node 自带 TextDecoder)。
* 两条接入路共用:IMAP 拉到的 source 与 wecom `read_mail` 的 EML 都从这里过。
*/
import PostalMime from 'postal-mime';
import type { Address } from 'postal-mime';
import type { MailAddress } from '@contracts';
import type { MimeParser, ParsedMail } from '../../port.ts';
function mailboxesOf(list: readonly Address[] | undefined): MailAddress[] {
const out: MailAddress[] = [];
for (const entry of list ?? []) {
if (entry.group !== undefined) {
for (const member of entry.group) out.push({ address: member.address, name: member.name });
} else if (entry.address !== undefined) {
out.push({ address: entry.address, name: entry.name });
}
}
return out;
}
/** References / In-Reply-To 是空白分隔的一串 <id>;拆开、去空 */
function idsOf(value: string | undefined): string[] {
if (value === undefined) return [];
return value
.split(/\s+/)
.map((s) => s.trim())
.filter((s) => s !== '');
}
function bytesOf(content: ArrayBuffer | Uint8Array | string): Uint8Array {
if (typeof content === 'string') return new TextEncoder().encode(content);
return content instanceof Uint8Array ? content : new Uint8Array(content);
}
export function createPostalMimeParser(): MimeParser {
return {
async parse(eml): Promise<ParsedMail> {
const email = await PostalMime.parse(eml, { attachmentEncoding: 'arraybuffer' });
const from =
email.from !== undefined && email.from.address !== undefined ? { address: email.from.address, name: email.from.name } : undefined;
const date = email.date === undefined ? undefined : new Date(email.date);
const inReplyTo = idsOf(email.inReplyTo)[0];
return {
...(email.messageId === undefined ? {} : { messageId: email.messageId }),
...(inReplyTo === undefined ? {} : { inReplyTo }),
references: idsOf(email.references),
...(from === undefined ? {} : { from }),
to: mailboxesOf(email.to),
cc: mailboxesOf(email.cc),
subject: email.subject ?? '',
...(email.text === undefined ? {} : { text: email.text }),
...(email.html === undefined ? {} : { html: email.html }),
...(date === undefined || Number.isNaN(date.getTime()) ? {} : { date }),
attachments: email.attachments.map((a) => ({
name: a.filename ?? '',
contentType: a.mimeType,
bytes: bytesOf(a.content),
})),
};
},
};
}
@@ -0,0 +1,212 @@
/**
* 企业微信邮件 API 适配器(外界;设计稿 §5.5;文档核实 2026-08-25):
* gettoken(corpid, corpsecret) → access_token(7200s)
* exmail/app/get_email_alias → 应用邮箱地址
* exmail/app/get_mail_list(begin_time, end_time, cursor, limit ≤ 1000) → mail_id(只有应用邮箱自己的收件箱)
* exmail/app/read_mail(mail_id) → mail_data(原始 EML;**文档未说编码** —— 像 EML 按原文,否则当 base64)
* exmail/app/compose_send → 发件人恒为应用邮箱;**不回 Message-ID**(线程级归属对它发出的信不成立)
* token 缓存在实例闭包里按**凭证指纹**存(不是模块级状态,也不是账号 id —— 见 `tokenKeyOf`);
* 提前 5 分钟刷新;40014 / 42001 刷一次重试;-1 重试 ≤3 次。
*/
import { createHash } from 'node:crypto';
import { wecomSettingsSchema } from '@contracts';
import type { MailProbeReport } from '@contracts';
import { wecomCursor, wecomWindow } from '../domain/cursor.ts';
import type { MailCursor } from '../domain/cursor.ts';
import { errorText } from '../domain/redact.ts';
import type { MailProvider, ProviderAccount, RawMessage } from '../../port.ts';
export type FetchLike = (
url: string,
init?: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal },
) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>;
type WecomResponse = Record<string, unknown> & { errcode?: number; errmsg?: string };
export class WecomApiError extends Error {
constructor(
readonly errcode: number,
readonly errmsg: string,
readonly api: string,
) {
super(`企业微信 ${api} 失败:errcode ${errcode} ${errmsg}`);
this.name = 'WecomApiError';
}
}
const TOKEN_EARLY_MS = 5 * 60_000;
const BUSY_RETRIES = 3;
const REQUEST_TIMEOUT_MS = 30_000;
const LIST_PAGE = 100;
/**
* 一轮最多翻几页 —— 服务端若一直回 `has_more:1` 且 next_cursor 不动,这个循环就永远转下去
* (只有单请求 30s 超时护着,而每一页都是一次成功返回,超时永远不触发)。
* 到顶就当本轮吃不完:`more = true`、游标照旧不推进,下一轮接着翻,一封都不丢。
*/
const MAX_LIST_PAGES = 50;
/**
* token 缓存的键 = **凭证本身的指纹**,不是 `account.id`。
*
* 按 id 存有两条真实的串号路径:
* ① 草稿探测共用 `id:'draft'`(用例层未存账号时就是这个字面量)—— 探完 A 公司再探 B 公司,
* B 会命中 A 的 token,`get_email_alias` 回的是 **A 的应用邮箱地址**,凭证填错的 B 也显示成功;
* ② 已存账号换了 corpId / corpSecret 而 id 不变 —— 下一轮轮询继续拿旧 token 拉**旧 corp 的收件箱**,
* 存进新配置的账号、分发给它绑的通道,窗口 ≤ 7200s。
* 凭证一变自然换槽,两条路都封死。**secret 不进 Map 的键**:哈希掉,内存里也不留明文。
*/
export function tokenKeyOf(corpId: string, corpSecret: string): string {
// JSON 分隔而不是裸拼:拼法有歧义(corpId 末尾多一个字符 = secret 首字少一个字符,指纹却相同)
return createHash('sha256').update(JSON.stringify([corpId, corpSecret])).digest('hex');
}
/** 文档只说「邮件 eml 内容」:头部行的形(`Name: value`)就按原文;否则当 base64 */
export function emlBytesOf(mailData: string): Uint8Array {
const looksLikeEml = /^[A-Za-z][A-Za-z0-9-]*:\s/m.test(mailData.slice(0, 2048));
return looksLikeEml ? new TextEncoder().encode(mailData) : new Uint8Array(Buffer.from(mailData, 'base64'));
}
export function createWecomProvider(deps: { fetch?: FetchLike; now?: () => number } = {}): MailProvider {
const doFetch: FetchLike = deps.fetch ?? ((url, init) => fetch(url, init));
const nowMs = deps.now ?? (() => Date.now());
const tokens = new Map<string, { token: string; expiresAt: number }>();
async function call(api: string, url: string, init?: Parameters<FetchLike>[1]): Promise<WecomResponse> {
for (let attempt = 0; ; attempt += 1) {
const res = await doFetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
if (!res.ok) throw new Error(`企业微信 ${api} HTTP ${res.status}`);
const body = (await res.json()) as WecomResponse;
const code = typeof body.errcode === 'number' ? body.errcode : 0;
if (code === 0) return body;
if (code === -1 && attempt < BUSY_RETRIES) continue;
throw new WecomApiError(code, String(body.errmsg ?? ''), api);
}
}
async function tokenOf(account: ProviderAccount, force = false): Promise<string> {
// 先取凭证再查缓存 —— 键就是凭证的指纹,取不到凭证也就无所谓缓存(见 tokenKeyOf)
const settings = wecomSettingsSchema.parse(account.settings);
const secret = account.secret['corpSecret'];
if (secret === undefined || secret === '') throw new Error('缺应用 secret');
const key = tokenKeyOf(settings.corpId, secret);
const cached = tokens.get(key);
if (!force && cached !== undefined && cached.expiresAt - TOKEN_EARLY_MS > nowMs()) return cached.token;
const body = await call(
'gettoken',
`${settings.apiBase}/cgi-bin/gettoken?corpid=${encodeURIComponent(settings.corpId)}&corpsecret=${encodeURIComponent(secret)}`,
{ method: 'GET' },
);
const token = typeof body['access_token'] === 'string' ? body['access_token'] : '';
if (token === '') throw new Error('企业微信 gettoken 没有返回 access_token');
const expiresIn = typeof body['expires_in'] === 'number' ? body['expires_in'] : 7200;
tokens.set(key, { token, expiresAt: nowMs() + expiresIn * 1000 });
return token;
}
async function withToken(account: ProviderAccount, api: string, path: string, payload?: unknown): Promise<WecomResponse> {
const settings = wecomSettingsSchema.parse(account.settings);
const run = (token: string): Promise<WecomResponse> =>
call(
api,
`${settings.apiBase}${path}?access_token=${encodeURIComponent(token)}`,
payload === undefined
? { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }
: { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) },
);
try {
return await run(await tokenOf(account));
} catch (error) {
if (error instanceof WecomApiError && (error.errcode === 40014 || error.errcode === 42001)) {
return run(await tokenOf(account, true));
}
throw error;
}
}
return {
kind: 'wecom',
async probe(account): Promise<MailProbeReport> {
const started = nowMs();
try {
const alias = await withToken(account, 'get_email_alias', '/cgi-bin/exmail/app/get_email_alias');
const address = typeof alias['email'] === 'string' ? alias['email'] : '';
return {
kind: 'wecom',
address,
receive: { ok: true, latencyMs: nowMs() - started },
send: { checked: false, note: '凭证有效;发信权限只能在真发时验证(compose_send 没有只验不发的口)' },
};
} catch (error) {
return { kind: 'wecom', receive: { ok: false, error: errorText(error) }, send: { checked: false, note: '收信凭证未通过,发信未验' } };
}
},
async anchor(_account, backfillFrom, now): Promise<MailCursor> {
return wecomCursor(Math.floor((backfillFrom ?? now).getTime() / 1000));
},
async pull(account, cursor, opts) {
const { beginTime, endTime } = wecomWindow(cursor, opts.backfillFrom, opts.now);
/*
* **先滤库里已有的,再数够不够** —— 顺序反了会卡死:一个窗口里若前 limit 条恰好都已入库,
* 这一轮就交出 0 封,而引擎见空批即收工、游标又没动,下一轮还是这 0 封,永远吃不到后面的新信。
* 滤在前,每一轮都实实在在往前吃 limit 封**新**的。
*/
const fresh: string[] = [];
let pageCursor: string | undefined;
let exhausted = false;
let pages = 0;
while (fresh.length < opts.limit && pages < MAX_LIST_PAGES) {
pages += 1;
const page = await withToken(account, 'get_mail_list', '/cgi-bin/exmail/app/get_mail_list', {
begin_time: beginTime,
end_time: endTime,
...(pageCursor === undefined ? {} : { cursor: pageCursor }),
limit: LIST_PAGE,
});
const list = Array.isArray(page['mail_list']) ? (page['mail_list'] as Array<{ mail_id?: unknown }>) : [];
const ids = list.flatMap((item) => (typeof item.mail_id === 'string' ? [item.mail_id] : []));
const known = await opts.known(ids);
for (const id of ids) if (!known.has(id)) fresh.push(id);
const hasMore = page['has_more'] === 1 || page['has_more'] === true;
pageCursor = typeof page['next_cursor'] === 'string' && page['next_cursor'] !== '' ? page['next_cursor'] : undefined;
if (!hasMore || list.length === 0 || pageCursor === undefined) {
exhausted = true;
break;
}
}
const wanted = fresh.slice(0, opts.limit);
const messages: RawMessage[] = [];
for (const mailId of wanted) {
const mail = await withToken(account, 'read_mail', '/cgi-bin/exmail/app/read_mail', { mail_id: mailId });
const data = mail['mail_data'];
if (typeof data !== 'string' || data === '') continue;
messages.push({ providerRef: mailId, eml: emlBytesOf(data), receivedAt: opts.now });
}
/*
* 游标推进与 `more` **必须由同一个判据决定**:只有「这一窗的新信全吃完了」才推到窗口上沿。
* `exhausted` 只说明**页翻完了** —— 最后一页可能同时 `has_more:0` 且新信多过本轮预算
* (`LIST_PAGE` 是 100,与 `opts.limit` 无关),那时 `wanted` 截掉的那些若跟着推进游标就**永久丢**了
* (重叠窗只兜 10 分钟)。退回不会卡住:已入库的下一轮被 `known` 滤掉,每轮照旧往前吃一批。
*/
const more = !exhausted || fresh.length > wanted.length;
return { messages, cursor: more ? cursor : wecomCursor(endTime), more };
},
async send(account, message) {
await withToken(account, 'compose_send', '/cgi-bin/exmail/app/compose_send', {
to: { emails: [...message.to] },
...(message.cc.length === 0 ? {} : { cc: { emails: [...message.cc] } }),
...(message.bcc.length === 0 ? {} : { bcc: { emails: [...message.bcc] } }),
subject: message.subject,
content: message.html ?? message.text ?? '',
content_type: message.html !== undefined ? 'html' : 'text',
...(message.attachments.length === 0
? {}
: { attachment_list: message.attachments.map((a) => ({ file_name: a.name, content: Buffer.from(a.bytes).toString('base64') })) }),
});
return {};
},
};
}
@@ -0,0 +1,106 @@
/** 账号记录 ↔ 入参 / 视图(设计稿 §5.3);视图经 schema 过一遍,密文进不来 */
import type { MailAccountInput, MailAccountKind, MailAccountView } from '@contracts';
import { mailAccountViewSchema } from '@contracts';
import { cursorSummary } from './cursor.ts';
import { isPolling } from './due.ts';
import type { MailAccountRecord } from './records.ts';
export function viewOf(record: MailAccountRecord, now: Date): MailAccountView {
return mailAccountViewSchema.parse({
id: record.id,
kind: record.kind,
name: record.name,
address: record.address,
displayName: record.displayName,
enabled: record.enabled,
isDefault: record.isDefault,
pollIntervalSec: record.pollIntervalSec,
channelId: record.channelId,
backfillFrom: record.backfillFrom === null ? null : record.backfillFrom.toISOString(),
settings: record.settings,
secretBound: record.secretCipher !== null && record.secretCipher !== '',
status: {
lastPolledAt: record.lastPolledAt === null ? null : record.lastPolledAt.toISOString(),
lastPollError: record.lastPollError,
lastInboundAt: record.lastInboundAt === null ? null : record.lastInboundAt.toISOString(),
cursor: cursorSummary(record.cursor),
polling: isPolling(record.pollingUntil, now),
},
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
});
}
/** 入参里的凭证 → 待密封的 JSON;没给就是 undefined(= 不改) */
export function secretOf(input: MailAccountInput): string | undefined {
return input.secret === undefined ? undefined : JSON.stringify(input.secret);
}
/**
* **只有收侧的键动了才谈得上重锚** —— 游标说的是「这个信箱 / 这个文件夹我读到哪儿了」。
*
* 从前比的是全部 settings 键,连 `smtpHost` / `smtpPort` / `smtpTls` 都算:
* 人把 465 改成 587(纯发信的事)→ 游标清空 → IMAP 重锚 `lastUid = uidNext − 1` →
* 上次游标到重锚之间到达的信**永远不拉**(设计即「不回填」)。**改发信的设置不该丢来信**。
* 登录名不在这张表里 —— 它由 `loginOf` 单独判(见下)。
*/
const RECEIVE_KEYS: Readonly<Record<MailAccountKind, readonly string[]>> = {
'imap-smtp': ['imapHost', 'imapPort', 'imapTls', 'folder'],
wecom: ['corpId', 'apiBase'],
};
function sameReceiveSettings(kind: MailAccountKind, a: Record<string, unknown>, b: Record<string, unknown>): boolean {
return RECEIVE_KEYS[kind].every((k) => JSON.stringify(a[k] ?? null) === JSON.stringify(b[k] ?? null));
}
/**
* imap-smtp **实际拿去登录的那个名字** = `login ?? address`(与 `imapSettingsOf` 同一句)。
*
* 只比 `login` 不够:它**常常空着**,这时候真正决定「读哪个信箱」的是 `address` ——
* 人把 address 换成另一个邮箱,IMAP 就登到别的箱子上去了,而旧游标的 UID 说的是原来那个箱子。
* 也不能改成比 `address`:显式填了 `login` 时,address 只是发信的落款,换它不动收信。
* wecom 不参与 —— 它的 address 由探测回填(不是人填的),比它会在回填那一刻误清游标。
*/
function loginOf(settings: Record<string, unknown>, address: string): string {
const login = settings['login'];
return typeof login === 'string' && login !== '' ? login : address;
}
/** kind 或**收侧** settings / 登录名变了,旧游标指的就不是同一个信箱 / 同一个文件夹 —— 清空重锚 */
export function cursorReset(existing: MailAccountRecord | undefined, input: MailAccountInput): boolean {
if (existing === undefined) return false;
if (existing.kind !== input.kind) return true;
if (!sameReceiveSettings(input.kind, existing.settings, input.settings)) return true;
if (input.kind !== 'imap-smtp') return false;
return loginOf(existing.settings, existing.address) !== loginOf(input.settings, input.address);
}
export function mergeAccount(
input: MailAccountInput,
base: { readonly id: string; readonly existing: MailAccountRecord | undefined; readonly secretCipher: string | null; readonly now: Date },
): MailAccountRecord {
const reset = cursorReset(base.existing, input);
const existing = base.existing;
return {
id: base.id,
kind: input.kind,
name: input.name,
// wecom 的地址由探测回填,人不填;换成 imap-smtp 时以填的为准
address: input.kind === 'imap-smtp' ? input.address : existing?.kind === 'wecom' ? existing.address : '',
displayName: input.displayName,
enabled: input.enabled,
isDefault: input.isDefault,
pollIntervalSec: input.pollIntervalSec,
channelId: input.channelId,
backfillFrom: input.backfillFrom === null ? null : new Date(input.backfillFrom),
settings: { ...input.settings },
secretCipher: base.secretCipher,
cursor: reset ? null : (existing?.cursor ?? null),
lastPolledAt: existing?.lastPolledAt ?? null,
lastPollError: reset ? null : (existing?.lastPollError ?? null),
lastInboundAt: existing?.lastInboundAt ?? null,
pollingUntil: existing?.pollingUntil ?? null,
createdAt: existing?.createdAt ?? base.now,
updatedAt: base.now,
};
}
@@ -0,0 +1,82 @@
/**
* 拉取游标 —— 两种接入路各一形(设计稿 §5.3 / §9.2)。
* IMAP:UIDVALIDITY + 最后一封 UID,逐封推进;wecom:时间窗上沿,逐批推进。
*/
export type ImapCursor = { readonly kind: 'imap'; readonly uidValidity: string; readonly lastUid: number };
export type WecomCursor = { readonly kind: 'wecom'; readonly lastTime: number };
export type MailCursor = ImapCursor | WecomCursor;
/** wecom 窗口往前退的重叠(秒):治两台机器的时钟偏;重复由唯一键挡 */
export const WECOM_OVERLAP_SEC = 600;
export function imapCursor(uidValidity: string, lastUid: number): ImapCursor {
return { kind: 'imap', uidValidity, lastUid };
}
export function advanceImap(cursor: ImapCursor, uid: number): ImapCursor {
return uid > cursor.lastUid ? { ...cursor, lastUid: uid } : cursor;
}
export function imapNeedsReanchor(cursor: MailCursor | null, uidValidity: string): boolean {
return cursor === null || cursor.kind !== 'imap' || cursor.uidValidity !== uidValidity;
}
/**
* IMAP 的 `n:*` 在 n 大于当前最大 UID 时仍会回最大那一封(`*` 语义)——
* 客户端必须再过滤一遍,否则每轮都重拉最后一封(唯一键挡得住,但白拉)。
*/
export function newUids(uids: readonly number[], lastUid: number): number[] {
return [...new Set(uids.filter((u) => u > lastUid))].sort((a, b) => a - b);
}
export function wecomCursor(lastTime: number): WecomCursor {
return { kind: 'wecom', lastTime };
}
export function wecomWindow(
cursor: MailCursor | null,
backfillFrom: Date | null,
now: Date,
): { beginTime: number; endTime: number } {
const endTime = Math.floor(now.getTime() / 1000);
if (cursor !== null && cursor.kind === 'wecom') {
return { beginTime: Math.max(0, cursor.lastTime - WECOM_OVERLAP_SEC), endTime };
}
if (backfillFrom !== null) return { beginTime: Math.floor(backfillFrom.getTime() / 1000), endTime };
// 无游标不回填:窗口空,这一轮只是把游标锚在当下
return { beginTime: endTime, endTime };
}
export function cursorSummary(cursor: MailCursor | null): string | null {
if (cursor === null) return null;
if (cursor.kind === 'imap') return `UID ${cursor.lastUid} · validity ${cursor.uidValidity}`;
return `至 ${new Date(cursor.lastTime * 1000).toISOString()}`;
}
/** IMAP 的 UID 是 32 位无符号(RFC 3501)—— 比它大的一定不是我们写下的那个数 */
const MAX_UID = 0xffff_ffff;
/**
* wecom 游标是 unix 秒,`cursorSummary` 要拿 `new Date(lastTime * 1000)` 渲。
* JS 的 Date 上限是 ±8.64e15 毫秒 —— 换算成秒就是这个数,超了 `toISOString()` 直接抛 RangeError。
* **安全整数还不够**:1e13 是安全整数,乘 1000 就出了 Date 的界。
*/
const MAX_UNIX_SECONDS = 8_640_000_000_000;
/**
* 从 jsonb 解回来 —— 形状不对当没有游标(下一轮重锚),不抛。
*
* **取值范围也算形状**:`{kind:'wecom', lastTime: 1e13}` 类型上是个 number,而
* `new Date(lastTime * 1000).toISOString()` 会抛 `RangeError` —— 而 `cursorSummary` 跑在 `viewOf` 里,
* 一行脏游标能让 `listAccounts` **整页 500**。外部够不着这一列(只有我们自己写游标),
* 但炸起来是整页,而拦住它只要一句:非负安全整数才算数。
*/
export function parseCursor(raw: unknown): MailCursor | null {
if (typeof raw !== 'object' || raw === null) return null;
const c = raw as Record<string, unknown>;
const within = (v: unknown, max: number): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 && v <= max;
if (c['kind'] === 'imap' && typeof c['uidValidity'] === 'string' && within(c['lastUid'], MAX_UID)) {
return imapCursor(c['uidValidity'], c['lastUid']);
}
if (c['kind'] === 'wecom' && within(c['lastTime'], MAX_UNIX_SECONDS)) return wecomCursor(c['lastTime']);
return null;
}
@@ -0,0 +1,10 @@
/** 分发状态机(设计稿 §9.3):dispatching → done | failed | skipped;重投只从「不在投」出发 */
import type { MailDeliveryState } from '@contracts';
export function canRedeliver(current: MailDeliveryState | undefined): boolean {
return current !== 'dispatching';
}
export function stateOfOutcome(outcome: void | { readonly skipped: string }): 'done' | 'skipped' {
return outcome !== undefined && typeof outcome === 'object' && 'skipped' in outcome ? 'skipped' : 'done';
}
@@ -0,0 +1,21 @@
/** 到期判定与租约时长(设计稿 §9.1) */
export const MIN_LEASE_MS = 5 * 60_000;
/** 租约 = max(5 分钟, 3 × 间隔):比最慢的一轮宽得多,又不至于让崩掉的进程锁上半天 */
export function leaseMsFor(pollIntervalSec: number): number {
return Math.max(MIN_LEASE_MS, 3 * pollIntervalSec * 1000);
}
export function isPolling(pollingUntil: Date | null, now: Date): boolean {
return pollingUntil !== null && pollingUntil.getTime() > now.getTime();
}
export function isDue(
account: { readonly enabled: boolean; readonly lastPolledAt: Date | null; readonly pollIntervalSec: number; readonly pollingUntil: Date | null },
now: Date,
): boolean {
if (!account.enabled) return false;
if (isPolling(account.pollingUntil, now)) return false;
if (account.lastPolledAt === null) return true;
return account.lastPolledAt.getTime() + account.pollIntervalSec * 1000 <= now.getTime();
}
@@ -0,0 +1,67 @@
/** 领域错误(编码规范 §5.3:领域错误住 domain,经 contract.ts 转出) */
export class MailAccountNotFoundError extends Error {
constructor(readonly accountId: string) {
super(`邮箱账号不存在:${accountId}`);
this.name = 'MailAccountNotFoundError';
}
}
export class MailAccountNameTakenError extends Error {
constructor(readonly accountName: string) {
super(`邮箱账号名已被占用:${accountName}`);
this.name = 'MailAccountNameTakenError';
}
}
export class MailAccountDisabledError extends Error {
constructor(readonly accountId: string) {
super(`邮箱账号已停用:${accountId}`);
this.name = 'MailAccountDisabledError';
}
}
export class MailNoDefaultAccountError extends Error {
constructor() {
super('没有可用的默认邮箱账号 —— 去运营中心 · 邮箱把某个账号设为默认');
this.name = 'MailNoDefaultAccountError';
}
}
export class MailSecretMissingError extends Error {
constructor(readonly accountId: string) {
super(`邮箱账号未配置凭证(客户端专用密码 / 应用 secret):${accountId}`);
this.name = 'MailSecretMissingError';
}
}
export class MailPollBusyError extends Error {
constructor(readonly accountId: string) {
super(`另一台后端正在拉取这个信箱,稍后再试:${accountId}`);
this.name = 'MailPollBusyError';
}
}
export class MailInboundNotFoundError extends Error {
constructor(readonly mailId: string) {
super(`这封信不在了:${mailId}`);
this.name = 'MailInboundNotFoundError';
}
}
export class MailAttachmentNotFoundError extends Error {
constructor(readonly mailId: string, readonly index: number) {
super(`附件没有存下来(超过上限或不存在):${mailId} #${index}`);
this.name = 'MailAttachmentNotFoundError';
}
}
export class MailSubscriberUnknownError extends Error {
constructor(readonly subscriber: string) {
super(`没有这个订阅方:${subscriber}`);
this.name = 'MailSubscriberUnknownError';
}
}
export class MailSubscriberTakenError extends Error {
constructor(readonly subscriber: string) {
super(`订阅方已注册,不许重名:${subscriber}`);
this.name = 'MailSubscriberTakenError';
}
}
export class MailSendInvalidError extends Error {
constructor(readonly problems: readonly string[]) {
super(`发信入参不合法:${problems.join('; ')}`);
this.name = 'MailSendInvalidError';
}
}
@@ -0,0 +1,210 @@
/**
* 解析结果 → 入站记录 / 视图(设计稿 §5.3)。零 IO:附件的存放由 usecase 按 `planAttachments` 去做,
* 存完把 ref 交回来再 `buildInboundRecord`。
*/
import type { MailAddress, MailDeliveryView, MailInboundDetail, MailInboundSummary } from '@contracts';
import type { DeliveryRecord, InboundAttachmentRecord, InboundRecord, ParsedAttachment, ParsedMail } from './records.ts';
/** 与 sys.upload 的上限同一个数(超了当场留注,不静默截断) */
export const ATTACHMENT_CAP = 8 * 1024 * 1024;
export const RAW_CAP = 16 * 1024 * 1024;
export const PREVIEW_CHARS = 200;
function domainOf(address: string): string {
const at = address.lastIndexOf('@');
return at < 0 || at === address.length - 1 ? 'local' : address.slice(at + 1);
}
/**
* **剥掉 NUL(U+0000)** —— PG 的 `text` / `jsonb` 列一律拒收它(`invalid byte sequence` / `unsupported Unicode escape`),
* 而 MIME 解出二进制垃圾时它真会出现(声明成 text/plain 的附件、编码标错的正文)。
*
* 落在这一层是因为:整封信卡住的病根在**一个字符**,而 domain 是纯函数、好测;
* 不剥的话这一封每 30 秒炸一次,游标停在它前面,**后面所有新信永远进不来**。
* 只剥这一个码点,别的字节原样留着 —— 原件 `raw` 是 bytea,一个字节都不动。
*/
export function stripNul(text: string): string {
return text.includes('\u0000') ? text.replaceAll('\u0000', '') : text;
}
function stripNulAddress(address: MailAddress): MailAddress {
return { address: stripNul(address.address), name: stripNul(address.name) };
}
/** 缺 Message-ID 的信(有的自动回复就没有)用 providerRef 顶上 —— 唯一键要的是一个稳定的串 */
export function fallbackMessageId(providerRef: string, address: string): string {
return `<${providerRef}@${domainOf(address)}>`;
}
/**
* 线程 = **这一串的根**(`References[0]`),不是最近一跳(ADR-013 决策 3)。
*
* 我方出站登记的 threadId 恒是首封(`channelTransport` 回 `input.threadId ?? messageId`,
* channel 据此 `recordOutbound`),而归属解析①级是精确等值比对。取最近一跳的话跑一个来回就漂:
* 我方 M1 → 客户回 M1(命中)→ 我方再回(实际 M2、仍记在 M1 名下)→ 客户回 M2 →
* 取到 M2 而库里从没记过 M2 ⇒ ①级落空,退到③地址 + 时间窗。
* `references[0]` 才稳定:客户端续链时首项恒是首封;客户端只给 In-Reply-To 时退回它。
*
* 入参放宽到 `string | null`:入站详情(`MailInboundDetail`)与解析结果(`ParsedMail`)
* 对「没有」的写法一个是 null 一个是 undefined,同一套口径要能同时接住两边 ——
* 这个函数就是那**唯一一处口径**(app 的 mail-wiring 调它,不再内联一份)。
*/
export function threadIdOf(parsed: { readonly inReplyTo?: string | null | undefined; readonly references: readonly string[] }): string | undefined {
const root = parsed.references[0];
if (root !== undefined && root !== '') return root;
return parsed.inReplyTo === null || parsed.inReplyTo === '' ? undefined : parsed.inReplyTo;
}
const ENTITIES: Readonly<Record<string, string>> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
export function stripHtml(html: string): string {
return html
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
.replace(/<br\s*\/?>|<\/(p|div|tr|li|h[1-6])>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (whole, code: string) => {
if (code.startsWith('#x') || code.startsWith('#X')) return String.fromCodePoint(Number.parseInt(code.slice(2), 16));
if (code.startsWith('#')) return String.fromCodePoint(Number(code.slice(1)));
return ENTITIES[code.toLowerCase()] ?? whole;
})
.replace(/[ \t\r\f\v]+/g, ' ')
.replace(/\s*\n\s*/g, ' ')
.trim();
}
export function bodyTextOf(parsed: Pick<ParsedMail, 'text' | 'html'>): string {
if (parsed.text !== undefined && parsed.text.trim() !== '') return parsed.text;
if (parsed.html !== undefined) return stripHtml(parsed.html);
return '';
}
export function textPreviewOf(text: string | null, html: string | null): string {
const body = bodyTextOf({ ...(text === null ? {} : { text }), ...(html === null ? {} : { html }) });
return body.slice(0, PREVIEW_CHARS);
}
export type AttachmentPlan = {
readonly index: number;
readonly name: string;
readonly contentType: string;
readonly size: number;
readonly store: boolean;
readonly note: string;
};
export function planAttachments(attachments: readonly ParsedAttachment[], cap: number = ATTACHMENT_CAP): AttachmentPlan[] {
return attachments.map((a, index) => {
const size = a.bytes.byteLength;
const store = size <= cap;
return {
index,
name: a.name === '' ? `attachment-${index + 1}` : a.name,
contentType: a.contentType === '' ? 'application/octet-stream' : a.contentType,
size,
store,
note: store ? '' : `附件 ${(size / 1024 / 1024).toFixed(1)}MB,超过 ${Math.round(cap / 1024 / 1024)}MB 上限,未存原件`,
};
});
}
/**
* 解析炸了之后从**原始字节**里扒一行头 —— 「主题能取多少取多少」那一手(降级存档用)。
*
* 不做 MIME 解码(encoded-word 原样留着):这一封本来就是解不动的那一封,
* 这里要的只是让人在列表上认出「是哪一封卡住了」,不是还原它。
* 头名只认 ASCII 字母与连字符 —— 别的一律当没有,不把外来串塞进正则。
*/
export function peekHeader(eml: Uint8Array, name: string): string {
if (!/^[A-Za-z][A-Za-z-]*$/.test(name)) return '';
const head = new TextDecoder('utf-8', { fatal: false }).decode(eml.slice(0, 16 * 1024));
const blank = head.search(/\r?\n\r?\n/);
const headers = blank < 0 ? head : head.slice(0, blank);
const hit = new RegExp(`^${name}:[ \\t]*(.*)$`, 'im').exec(headers);
return stripNul((hit?.[1] ?? '').trim());
}
export function rawOf(eml: Uint8Array, cap: number = RAW_CAP): { raw: Uint8Array | null; note: string } {
if (eml.byteLength <= cap) return { raw: eml, note: '' };
return { raw: null, note: `原件 ${(eml.byteLength / 1024 / 1024).toFixed(1)}MB,超过 ${Math.round(cap / 1024 / 1024)}MB 上限,未存` };
}
export function buildInboundRecord(input: {
readonly id: string;
readonly accountId: string;
readonly address: string;
readonly providerRef: string;
readonly parsed: ParsedMail;
readonly eml: Uint8Array;
readonly receivedAt: Date;
readonly attachments: readonly InboundAttachmentRecord[];
readonly now: Date;
readonly rawCap?: number | undefined;
}): InboundRecord {
const { parsed } = input;
const messageId = parsed.messageId !== undefined && parsed.messageId !== '' ? parsed.messageId : fallbackMessageId(input.providerRef, input.address);
const { raw, note } = rawOf(input.eml, input.rawCap ?? RAW_CAP);
// 凡是要落 text / jsonb 的字段一律先剥 NUL(见 stripNul);raw 是 bytea,原样存
return {
id: input.id,
accountId: input.accountId,
messageId: stripNul(messageId),
providerRef: stripNul(input.providerRef),
inReplyTo: parsed.inReplyTo === undefined ? null : stripNul(parsed.inReplyTo),
references: parsed.references.map(stripNul),
from: stripNulAddress(parsed.from ?? { address: '', name: '' }),
to: parsed.to.map(stripNulAddress),
cc: parsed.cc.map(stripNulAddress),
subject: stripNul(parsed.subject),
text: parsed.text === undefined ? null : stripNul(parsed.text),
html: parsed.html === undefined ? null : stripNul(parsed.html),
sentAt: parsed.date ?? null,
receivedAt: input.receivedAt,
attachments: input.attachments.map((a) => ({ ...a, name: stripNul(a.name), contentType: stripNul(a.contentType), note: stripNul(a.note) })),
raw,
rawAvailable: raw !== null,
rawNote: note,
size: input.eml.byteLength,
createdAt: input.now,
};
}
export function deliveryViewOf(d: DeliveryRecord): MailDeliveryView {
return {
subscriber: d.subscriber,
state: d.state,
error: d.error,
startedAt: d.startedAt.toISOString(),
finishedAt: d.finishedAt === null ? null : d.finishedAt.toISOString(),
};
}
export function summaryOf(record: InboundRecord, deliveries: readonly DeliveryRecord[]): MailInboundSummary {
return {
id: record.id,
accountId: record.accountId,
messageId: record.messageId,
from: record.from,
to: [...record.to],
cc: [...record.cc],
subject: record.subject,
sentAt: record.sentAt === null ? null : record.sentAt.toISOString(),
receivedAt: record.receivedAt.toISOString(),
textPreview: textPreviewOf(record.text, record.html),
hasHtml: record.html !== null,
attachments: record.attachments.map((a) => ({ ...a })),
deliveries: deliveries.map(deliveryViewOf),
};
}
export function detailOf(record: InboundRecord, deliveries: readonly DeliveryRecord[]): MailInboundDetail {
return {
...summaryOf(record, deliveries),
inReplyTo: record.inReplyTo,
references: [...record.references],
text: record.text,
html: record.html,
size: record.size,
// 问投影出来的那一格,不问字节在不在手上 —— 列表 / 元信息读压根不取字节
rawAvailable: record.rawAvailable,
};
}
@@ -0,0 +1,135 @@
/** 库形与传输形 —— 纯类型,零逻辑(设计稿 §5.2) */
import type { MailAccountKind, MailAddress, MailDeliveryState, MailOutboundState } from '@contracts';
import type { MailCursor } from './cursor.ts';
export type MailAccountRecord = {
readonly id: string;
readonly kind: MailAccountKind;
readonly name: string;
readonly address: string;
readonly displayName: string;
readonly enabled: boolean;
readonly isDefault: boolean;
readonly pollIntervalSec: number;
readonly channelId: string | null;
readonly backfillFrom: Date | null;
/** 按 kind 的非密配置(imapSmtpSettingsSchema / wecomSettingsSchema 的形) */
readonly settings: Record<string, unknown>;
/** 密封的 JSON:{password} | {corpSecret};永不出接口 */
readonly secretCipher: string | null;
readonly cursor: MailCursor | null;
readonly lastPolledAt: Date | null;
readonly lastPollError: string | null;
readonly lastInboundAt: Date | null;
readonly pollingUntil: Date | null;
readonly createdAt: Date;
readonly updatedAt: Date;
};
export type InboundAttachmentRecord = {
readonly index: number;
readonly name: string;
readonly contentType: string;
readonly size: number;
readonly ref: string | null;
readonly note: string;
};
export type InboundRecord = {
readonly id: string;
readonly accountId: string;
readonly messageId: string;
readonly providerRef: string;
readonly inReplyTo: string | null;
readonly references: readonly string[];
readonly from: MailAddress;
readonly to: readonly MailAddress[];
readonly cc: readonly MailAddress[];
readonly subject: string;
readonly text: string | null;
readonly html: string | null;
readonly sentAt: Date | null;
readonly receivedAt: Date;
readonly attachments: readonly InboundAttachmentRecord[];
/**
* 原件字节 —— **只有「下载原件」那一条读取回它**(`InboundStore.get`);
* 列表与只要元信息的读一律不取(一封上限 16MB),那时这里恒是 null。
* 「有没有原件」问 `rawAvailable`,别拿 `raw !== null` 去问 —— 那两件事只在全列读时才等价。
*/
readonly raw: Uint8Array | null;
/** 库里那一行有没有原件(`raw is not null` 投影出来的,不靠把字节搬回来再判) */
readonly rawAvailable: boolean;
readonly rawNote: string;
readonly size: number;
readonly createdAt: Date;
};
export type DeliveryRecord = {
readonly mailId: string;
readonly subscriber: string;
readonly state: MailDeliveryState;
readonly error: string | null;
readonly startedAt: Date;
readonly finishedAt: Date | null;
};
export type OutboundAttachmentRecord = {
readonly name: string;
readonly contentType: string;
readonly size: number;
readonly ref: string | null;
};
export type OutboundRecord = {
readonly id: string;
readonly accountId: string;
readonly messageId: string;
readonly idempotencyKey: string | null;
readonly to: readonly string[];
readonly cc: readonly string[];
readonly bcc: readonly string[];
readonly subject: string;
readonly text: string | null;
readonly html: string | null;
readonly inReplyTo: string | null;
readonly references: readonly string[];
readonly attachments: readonly OutboundAttachmentRecord[];
readonly state: MailOutboundState;
readonly error: string | null;
readonly providerRef: string | null;
readonly requestedBy: Record<string, unknown>;
readonly createdAt: Date;
readonly sentAt: Date | null;
};
export type ParsedAttachment = { readonly name: string; readonly contentType: string; readonly bytes: Uint8Array };
/** MIME 解析结果 —— 地址、Message-ID、References 已归一为裸串 */
export type ParsedMail = {
readonly messageId?: string | undefined;
readonly inReplyTo?: string | undefined;
readonly references: readonly string[];
readonly from?: MailAddress | undefined;
readonly to: readonly MailAddress[];
readonly cc: readonly MailAddress[];
readonly subject: string;
readonly text?: string | undefined;
readonly html?: string | undefined;
readonly date?: Date | undefined;
readonly attachments: readonly ParsedAttachment[];
};
/** 交给 provider 的出信 —— 附件已是字节 */
export type OutboundMessage = {
readonly messageId: string;
readonly from: MailAddress;
readonly to: readonly string[];
readonly cc: readonly string[];
readonly bcc: readonly string[];
readonly subject: string;
readonly text?: string | undefined;
readonly html?: string | undefined;
readonly inReplyTo?: string | undefined;
readonly references: readonly string[];
readonly attachments: readonly ParsedAttachment[];
};
@@ -0,0 +1,49 @@
/**
* 脱敏 —— 凡要落库 / 上接口的厂商原话都先过这里(设计稿 §9.4)。
* 明文、base64(SMTP AUTH 的形)、URL 编码三形都换掉。
*/
import { stripNul } from './inbound-record.ts';
const MIN_SECRET_CHARS = 4;
export function secretVariants(secret: string): string[] {
if (secret.length < MIN_SECRET_CHARS) return [];
const variants = [secret, Buffer.from(secret, 'utf8').toString('base64'), encodeURIComponent(secret)];
return [...new Set(variants.filter((v) => v.length >= MIN_SECRET_CHARS))];
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function redactSecrets(text: string, secrets: readonly string[]): string {
let out = text;
for (const secret of secrets) {
for (const variant of secretVariants(secret)) {
out = out.replace(new RegExp(escapeRegex(variant), 'g'), '***');
}
}
return out;
}
/**
* 错误 → 一句话;nodemailer / imapflow 把服务器回话挂在 `response` / `responseText` 上,一并带上。
*
* **出口一律剥 NUL**:这句话的去向全是 PG 的 `text` 列(`last_poll_error` / 投递的 `error` /
* 降级存档行的正文),而它的来源常常是**解析失败的那封信自己的字节** —— 一个 U+0000 就能让
* 「记下这次失败」这一笔自己也写不进去。剥在这里,是因为所有落库的错误话都从这个出口出去;
* 分散到每个调用点去剥,总会漏掉下一个新调用点。
*/
export function errorText(error: unknown): string {
return stripNul(rawErrorText(error));
}
function rawErrorText(error: unknown): string {
if (error instanceof Error) {
const bag = error as Error & { response?: unknown; responseText?: unknown };
const response = typeof bag.response === 'string' ? bag.response : typeof bag.responseText === 'string' ? bag.responseText : '';
return response === '' || response === error.message ? error.message : `${error.message} —— ${response}`;
}
if (error === undefined || error === null) return '未知错误';
return String(error);
}
@@ -0,0 +1,60 @@
/** 发信校验与选账号(设计稿 §5.3 / §5.4) */
import type { MailAccountKind, MailSendInput } from '@contracts';
import { MailAccountDisabledError, MailAccountNotFoundError, MailNoDefaultAccountError } from './errors.ts';
import type { MailAccountRecord } from './records.ts';
export const SEND_ATTACHMENT_CAP = 8 * 1024 * 1024;
export const SEND_TOTAL_CAP = 20 * 1024 * 1024;
/** 企业微信文档:所有附件加正文不超过 50M,附件个数不超过 200 */
export const WECOM_TOTAL_CAP = 50 * 1024 * 1024;
export const WECOM_ATTACHMENT_COUNT_CAP = 200;
type SendShape = Pick<MailSendInput, 'to' | 'cc' | 'bcc' | 'subject' | 'text' | 'html' | 'attachments'>;
export function validateSend(input: SendShape, kind: MailAccountKind, attachmentSizes: readonly number[]): string[] {
const problems: string[] = [];
if (input.to.length === 0) problems.push('收件人至少一个');
if (input.subject.trim() === '') problems.push('主题不能为空');
if ((input.text === undefined || input.text === '') && (input.html === undefined || input.html === '')) {
problems.push('正文 text / html 至少一个');
}
const total = attachmentSizes.reduce((sum, n) => sum + n, 0);
if (kind === 'wecom') {
if (attachmentSizes.length > WECOM_ATTACHMENT_COUNT_CAP) problems.push(`企业微信附件不能超过 ${WECOM_ATTACHMENT_COUNT_CAP} 个`);
if (total + (input.text?.length ?? 0) + (input.html?.length ?? 0) > WECOM_TOTAL_CAP) problems.push('企业微信正文加附件不能超过 50MB');
} else {
const oversize = attachmentSizes.findIndex((n) => n > SEND_ATTACHMENT_CAP);
if (oversize >= 0) problems.push(`第 ${oversize + 1} 个附件超过 8MB`);
if (total > SEND_TOTAL_CAP) problems.push('附件合计超过 20MB');
}
return problems;
}
export function pickAccount(
accounts: readonly MailAccountRecord[],
requested: { readonly accountId?: string | undefined; readonly from?: string | undefined },
): MailAccountRecord {
if (requested.accountId !== undefined) {
const hit = accounts.find((a) => a.id === requested.accountId);
if (hit === undefined) throw new MailAccountNotFoundError(requested.accountId);
if (!hit.enabled) throw new MailAccountDisabledError(hit.id);
return hit;
}
if (requested.from !== undefined) {
const wanted = requested.from.trim().toLowerCase();
const hit = accounts.find((a) => a.address.toLowerCase() === wanted);
// 对不上就是对不上:拿别的账号冒充这个发件地址,SMTP 会拒,人也会被骗
if (hit === undefined) throw new MailAccountNotFoundError(requested.from);
if (!hit.enabled) throw new MailAccountDisabledError(hit.id);
return hit;
}
const fallback = accounts.find((a) => a.isDefault && a.enabled);
if (fallback === undefined) throw new MailNoDefaultAccountError();
return fallback;
}
export function messageIdFor(id: string, address: string): string {
const at = address.lastIndexOf('@');
const domain = at < 0 || at === address.length - 1 ? 'local' : address.slice(at + 1);
return `<${id}@${domain}>`;
}
@@ -0,0 +1,652 @@
/**
* mail 用例 —— 账号、收、发(编码规范 §5.3;设计稿 §5.4;ADR-013)。
*
* 收:抢租约 → 无游标先锚 → 循环 pull(封顶 50 封 / 20s)→ 逐封解析 / 存附件 / 入库(唯一键去重)
* → 逐封推进游标 → 新入库的分发给订阅方 → 还租约。任何异常:记 last_poll_error(脱敏),游标停在最后一封成功处。
* 发:选账号 → 幂等键 → 校验 → 落 sending → provider → sent / failed(原话脱敏);厂商失败不抛。
*/
import type { MailAccountKind, MailPollOutcome, MailProbeReport, MailSendResult } from '@contracts';
import type { InboundHandler, MailService } from '../../contract.ts';
import { cursorReset, mergeAccount, secretOf, viewOf } from '../domain/account-view.ts';
import { canRedeliver, stateOfOutcome } from '../domain/delivery.ts';
import { leaseMsFor } from '../domain/due.ts';
import {
MailAccountDisabledError,
MailAccountNameTakenError,
MailAccountNotFoundError,
MailAttachmentNotFoundError,
MailInboundNotFoundError,
MailPollBusyError,
MailSecretMissingError,
MailSendInvalidError,
MailSubscriberTakenError,
MailSubscriberUnknownError,
} from '../domain/errors.ts';
import { ATTACHMENT_CAP, buildInboundRecord, deliveryViewOf, detailOf, peekHeader, planAttachments, RAW_CAP, summaryOf } from '../domain/inbound-record.ts';
import type { DeliveryRecord, InboundAttachmentRecord, InboundRecord, MailAccountRecord, OutboundRecord, ParsedAttachment } from '../domain/records.ts';
import { errorText, redactSecrets } from '../domain/redact.ts';
import { messageIdFor, pickAccount, validateSend } from '../domain/send-input.ts';
import type {
AttachmentStore,
ChangeNotifier,
Clock,
IdGenerator,
InboundStore,
MailAccountStore,
MailProvider,
MimeParser,
OutboundStore,
ProviderAccount,
RawMessage,
SecretBox,
} from '../../port.ts';
export type MailServiceDeps = {
readonly accounts: MailAccountStore;
readonly inbound: InboundStore;
readonly outbound: OutboundStore;
readonly attachments: AttachmentStore;
readonly providers: Readonly<Record<MailAccountKind, MailProvider>>;
readonly mime: MimeParser;
readonly secrets: SecretBox;
readonly clock: Clock;
readonly ids: IdGenerator;
readonly notify: ChangeNotifier;
readonly limits?:
| {
readonly perTick?: number | undefined;
readonly tickBudgetMs?: number | undefined;
readonly attachmentBytes?: number | undefined;
readonly rawBytes?: number | undefined;
}
| undefined;
};
const DEFAULT_LIST_LIMIT = 50;
const MAX_LIST_LIMIT = 200;
/**
* 同键的上一封停在 `sending` 多久之后,算作「上次崩在半路」而不是「正在飞」。
* 这两种局面在库里长得一模一样,只能靠时限分开:短于此值一律不发(宁可让调用方再试一次,
* 也不能发出第二封真邮件);长于此值就当那一行没人管了,复用它重发。
*/
const SEND_STALE_MS = 5 * 60_000;
/**
* 一条投递停在 `dispatching` 多久之后,允许**人手**的「重新投递」把它接管过去。
* 自动路径永远不重投(那是防双投的墙);但进程若崩在写 dispatching 与写结果之间,
* 那条留痕就永远卡着 —— 墙要配路:人按的那一下得有门进。
*/
const DISPATCH_STALE_MS = 10 * 60_000;
type PollTally = { accountId: string; pulled: number; stored: number; dispatched: number; more: boolean; error?: string };
function tallyToOutcome(t: PollTally): MailPollOutcome {
return {
accountId: t.accountId,
pulled: t.pulled,
stored: t.stored,
dispatched: t.dispatched,
more: t.more,
...(t.error === undefined ? {} : { error: t.error }),
};
}
function safeFileName(messageId: string): string {
const cleaned = messageId.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
return cleaned === '' ? 'mail' : cleaned;
}
function resultOf(record: OutboundRecord): MailSendResult {
return {
outboundId: record.id,
accountId: record.accountId,
messageId: record.messageId,
state: record.state === 'sent' ? 'sent' : 'failed',
...(record.error === null ? {} : { error: record.error }),
...(record.providerRef === null ? {} : { providerRef: record.providerRef }),
};
}
export function createMailService(deps: MailServiceDeps): MailService {
const limits = {
perTick: deps.limits?.perTick ?? 50,
tickBudgetMs: deps.limits?.tickBudgetMs ?? 20_000,
attachmentBytes: deps.limits?.attachmentBytes ?? ATTACHMENT_CAP,
rawBytes: deps.limits?.rawBytes ?? RAW_CAP,
};
const subscribers = new Map<string, InboundHandler>();
async function requireAccount(accountId: string): Promise<MailAccountRecord> {
const record = await deps.accounts.get(accountId);
if (record === undefined) throw new MailAccountNotFoundError(accountId);
return record;
}
/** 明文只在这里打开、交给 provider 那一段;不进任何留痕 */
function openSecret(record: Pick<MailAccountRecord, 'id' | 'secretCipher'>): Record<string, string> {
if (record.secretCipher === null || record.secretCipher === '') throw new MailSecretMissingError(record.id);
/*
* 解密与解析都包进来:`JSON.parse` 抛的 SyntaxError **把被解析的原文嵌在 message 里**
* (V8:`Unexpected token 'p', "pw-1234" is not valid JSON`),而这一抛发生在调用方把 `secret`
* 赋值**之前** —— 脱敏那一层此刻手里是空的、一个字都洗不掉,明文会经 `savePoll({error})`
* 落进 `last_poll_error`,再由账号视图上到运营台。
* 解不开就是解不开:一律换成「未配置凭证」,原文一个字不外传。
*/
let parsed: unknown;
try {
parsed = JSON.parse(deps.secrets.open(record.secretCipher));
} catch {
throw new MailSecretMissingError(record.id);
}
if (typeof parsed !== 'object' || parsed === null) throw new MailSecretMissingError(record.id);
return Object.fromEntries(Object.entries(parsed as Record<string, unknown>).map(([k, v]) => [k, String(v)]));
}
function providerAccount(record: MailAccountRecord, secret: Record<string, string>): ProviderAccount {
return { id: record.id, kind: record.kind, address: record.address, displayName: record.displayName, settings: record.settings, secret };
}
function redactWith(secret: Record<string, string>): (text: string) => string {
const values = Object.values(secret);
return (text) => redactSecrets(text, values);
}
function redactReport(report: MailProbeReport, secret: Record<string, string>): MailProbeReport {
const redact = redactWith(secret);
return {
...report,
receive: { ...report.receive, ...(report.receive.error === undefined ? {} : { error: redact(report.receive.error) }) },
send: { ...report.send, ...(report.send.error === undefined ? {} : { error: redact(report.send.error) }) },
};
}
function limitOf(query: { limit?: number | undefined }): number {
return Math.min(MAX_LIST_LIMIT, Math.max(1, query.limit ?? DEFAULT_LIST_LIMIT));
}
/* ── 分发 ────────────────────────────────────────────────────────── */
async function dispatch(
record: InboundRecord,
ctx: { accountId: string; channelId: string | null },
only?: string,
opts: { readonly reclaimStale?: boolean } = {},
): Promise<DeliveryRecord[]> {
// 只要那几条投递痕,不必把 16MB 的原件搬过来
const existing = (await deps.inbound.getMeta(record.id))?.deliveries ?? [];
const out: DeliveryRecord[] = [];
for (const [name, handler] of subscribers) {
if (only !== undefined && name !== only) continue;
const current = existing.find((d) => d.subscriber === name);
// 正在投的那条不重投:进程还没回话,再投一遍就是两条线同时改一件事
const stale =
opts.reclaimStale === true &&
current?.state === 'dispatching' &&
deps.clock.now().getTime() - current.startedAt.getTime() >= DISPATCH_STALE_MS;
if (!canRedeliver(current?.state) && !stale) {
if (current !== undefined) out.push(current);
continue;
}
const startedAt = deps.clock.now();
const dispatching: DeliveryRecord = { mailId: record.id, subscriber: name, state: 'dispatching', error: null, startedAt, finishedAt: null };
await deps.inbound.setDelivery(dispatching);
let settled: DeliveryRecord;
try {
const outcome = await handler(detailOf(record, existing), { accountId: ctx.accountId, channelId: ctx.channelId ?? undefined });
const state = stateOfOutcome(outcome);
settled = {
...dispatching,
state,
// skipped 的理由也放 error 列:人在列表上看得见「为什么没投」
error: state === 'skipped' && outcome !== undefined && typeof outcome === 'object' ? outcome.skipped : null,
finishedAt: deps.clock.now(),
};
} catch (error) {
settled = { ...dispatching, state: 'failed', error: errorText(error), finishedAt: deps.clock.now() };
}
await deps.inbound.setDelivery(settled);
out.push(settled);
}
return out;
}
/* ── 拉取 ────────────────────────────────────────────────────────── */
async function storeOne(record: MailAccountRecord, message: RawMessage): Promise<InboundRecord | undefined> {
// 先按 provider 给的引用查重,免得重复拉到的信把附件再存一遍
const known = await deps.inbound.knownProviderRefs(record.id, [message.providerRef]);
if (known.has(message.providerRef)) return undefined;
const parsed = await deps.mime.parse(message.eml);
const attachments: InboundAttachmentRecord[] = [];
for (const plan of planAttachments(parsed.attachments, limits.attachmentBytes)) {
let ref: string | null = null;
if (plan.store) {
const bytes = parsed.attachments[plan.index]?.bytes ?? new Uint8Array();
ref = (await deps.attachments.put({ name: plan.name, contentType: plan.contentType, bytes, note: `邮件附件:${parsed.subject}` })).ref;
}
attachments.push({ index: plan.index, name: plan.name, contentType: plan.contentType, size: plan.size, ref, note: plan.note });
}
const built = buildInboundRecord({
id: deps.ids.next(),
accountId: record.id,
address: record.address,
providerRef: message.providerRef,
parsed,
eml: message.eml,
receivedAt: message.receivedAt,
attachments,
now: deps.clock.now(),
rawCap: limits.rawBytes,
});
const { inserted } = await deps.inbound.insert(built);
return inserted ? built : undefined;
}
/**
* 单封解析失败的**退路**:先试着落一行最小骨架(照迁移 0033 的 NOT NULL 填),落成了就推进游标。
*
* 没有这条路时,一封「毒信」把整个账号永久卡死:`storeOne` 抛错 → 外层 catch 接住 →
* 游标停在这一封**之前** → 下一 tick 从同一个 UID 重拉、同样抛,**后面所有新信永远进不来**,
* 而运营面只有一行 `last_poll_error`、没有任何钮能跳过它。
* 骨架行不是「跳过」(那是丢信):原件 `raw` 原样存着,主题能扒多少扒多少,
* 正文写明这是降级行与失败原因,人在列表上看得见、点得开、可按「重新投递」。
*/
async function storeDegraded(
record: MailAccountRecord,
message: RawMessage,
cause: unknown,
): Promise<{ record: InboundRecord; inserted: boolean } | undefined> {
try {
const subject = peekHeader(message.eml, 'Subject');
const built = buildInboundRecord({
id: deps.ids.next(),
accountId: record.id,
address: record.address,
providerRef: message.providerRef,
parsed: {
references: [],
to: [],
cc: [],
subject: subject === '' ? '(主题解析失败)' : subject,
text: `【降级存档】这一封的 MIME 解析或入库失败,原文见「下载原件」。失败原因:${errorText(cause)}`,
attachments: [],
},
eml: message.eml,
receivedAt: message.receivedAt,
attachments: [],
now: deps.clock.now(),
rawCap: limits.rawBytes,
});
// 唯一键撞上(这一封先前已经降级存过)也算「落下了」:那一行就在库里,照常越过去,只是不重复计数
const { inserted } = await deps.inbound.insert(built);
return { record: built, inserted };
} catch {
// 骨架都落不下 —— 交给调用方停在这一封并点名是谁
return undefined;
}
}
async function pollAccount(record: MailAccountRecord): Promise<MailPollOutcome> {
const claimed = await deps.accounts.claimPolling(record.id, leaseMsFor(record.pollIntervalSec));
if (!claimed) throw new MailPollBusyError(record.id);
const tally: PollTally = { accountId: record.id, pulled: 0, stored: 0, dispatched: 0, more: false };
/** 这一轮的「有话说」:降级存档了哪几封、provider 报的重锚说明 —— 顺利收尾也要落进 last_poll_error */
const notes: string[] = [];
let secret: Record<string, string> = {};
try {
secret = openSecret(record);
const provider = deps.providers[record.kind];
const account = providerAccount(record, secret);
const started = deps.clock.now();
let cursor = record.cursor;
if (cursor === null) {
cursor = await provider.anchor(account, record.backfillFrom, started);
await deps.accounts.savePoll(record.id, { cursor });
}
let budget = limits.perTick;
let more = true;
let seq = 0;
while (more && budget > 0 && deps.clock.now().getTime() - started.getTime() < limits.tickBudgetMs) {
const batch = await provider.pull(account, cursor, {
limit: budget,
now: deps.clock.now(),
backfillFrom: record.backfillFrom,
known: (refs) => deps.inbound.knownProviderRefs(record.id, refs),
});
// provider 有话说(如 UIDVALIDITY 变化已重锚)—— 这一轮不算失败,但界面上必须看得见
if (batch.note !== undefined && batch.note !== '') notes.push(batch.note);
tally.pulled += batch.messages.length;
budget -= batch.messages.length;
for (const message of batch.messages) {
seq += 1;
let stored: InboundRecord | undefined;
let degraded = false;
try {
stored = await storeOne(record, message);
} catch (error) {
/*
* **一封解析不动的信不许卡死整条路**:先落降级骨架,落成了就照常推进游标。
* 骨架也落不下才停在这一封 —— 那时 `last_poll_error` 必须点名是哪一封(UID / providerRef),
* 否则人只看得见一句「解析失败」,不知道是谁卡着、更不知道能不能跳过。
*/
const fallback = await storeDegraded(record, message, error);
if (fallback === undefined) {
throw new Error(`第 ${seq} 封(${message.providerRef})存不下,降级骨架也落不下,这一轮停在它这里:${errorText(error)}`);
}
degraded = true;
stored = fallback.inserted ? fallback.record : undefined;
notes.push(`第 ${seq} 封(${message.providerRef})解析失败,已降级存档:${errorText(error)}`);
}
// 逐封推进:中途栽了,下一轮从这一封之后接着
if (message.cursorAfter !== undefined) {
cursor = message.cursorAfter;
await deps.accounts.savePoll(record.id, { cursor });
}
if (stored !== undefined) {
tally.stored += 1;
await deps.accounts.savePoll(record.id, { lastInboundAt: stored.receivedAt });
/*
* 降级行**不自动分发**:它的正文是失败原因,不是客户说的话 —— 投给通道等于让下游
* 按一段垃圾正文起单。它照旧躺在入站列表里(带原件),人看过之后可以按「重新投递」。
*/
if (!degraded) {
await dispatch(stored, { accountId: record.id, channelId: record.channelId });
tally.dispatched += 1;
}
}
}
cursor = batch.cursor;
await deps.accounts.savePoll(record.id, { cursor });
more = batch.more;
if (batch.messages.length === 0) break;
}
tally.more = more;
// 这一轮没崩,但有话要说的照说(降级存档 / 重锚)—— 一片绿不是实情
const note = notes.length === 0 ? null : notes.join(';');
if (note !== null) tally.error = note;
await deps.accounts.savePoll(record.id, { polledAt: deps.clock.now(), error: note });
} catch (error) {
const text = redactWith(secret)(errorText(error));
tally.error = text;
await deps.accounts.savePoll(record.id, { polledAt: deps.clock.now(), error: text });
} finally {
await deps.accounts.releasePolling(record.id);
}
if (tally.stored > 0 || tally.error !== undefined) deps.notify.changed('inbound');
return tallyToOutcome(tally);
}
/* ── 服务 ────────────────────────────────────────────────────────── */
return {
async listAccounts() {
const now = deps.clock.now();
return (await deps.accounts.list()).map((r) => viewOf(r, now));
},
async getAccount(accountId) {
return viewOf(await requireAccount(accountId), deps.clock.now());
},
async createAccount(input) {
if ((await deps.accounts.findByName(input.name)) !== undefined) throw new MailAccountNameTakenError(input.name);
const secretJson = secretOf(input);
const now = deps.clock.now();
const record = mergeAccount(input, {
id: deps.ids.next(),
existing: undefined,
secretCipher: secretJson === undefined ? null : deps.secrets.seal(secretJson),
now,
});
await deps.accounts.save(record);
return viewOf(record, now);
},
async updateAccount(accountId, input) {
const existing = await requireAccount(accountId);
const clash = await deps.accounts.findByName(input.name);
if (clash !== undefined && clash.id !== accountId) throw new MailAccountNameTakenError(input.name);
const secretJson = secretOf(input);
// 只写不读:secret 留空即保持原密文;换了 kind 旧密文不是这一形的,作废
const secretCipher =
secretJson !== undefined ? deps.secrets.seal(secretJson) : input.kind === existing.kind ? existing.secretCipher : null;
const now = deps.clock.now();
// kind / **收侧** settings 变了游标清空重锚 —— 判定在 mergeAccount 里(cursorReset)
const record = mergeAccount(input, { id: accountId, existing, secretCipher, now });
await deps.accounts.save(record);
/*
* `save()` 只写配置列(不然会盖掉另一台正持着的租约与刚推进的游标),所以清游标要**显式单写**这一列。
* 上一轮的错误话跟着清:它说的是旧配置下的事,留着会让人对着新配置读旧毛病。
*/
if (cursorReset(existing, input)) await deps.accounts.savePoll(accountId, { cursor: null, error: null });
return viewOf(record, now);
},
async deleteAccount(accountId) {
await requireAccount(accountId);
await deps.accounts.remove(accountId);
},
async probe(target) {
if ('input' in target) {
const stored = target.accountId === undefined ? undefined : await requireAccount(target.accountId);
const secretJson = secretOf(target.input);
const secret =
secretJson !== undefined
? (JSON.parse(secretJson) as Record<string, string>)
: stored !== undefined && stored.kind === target.input.kind
? openSecret(stored)
: (() => {
throw new MailSecretMissingError(target.accountId ?? 'draft');
})();
const draft = mergeAccount(target.input, { id: stored?.id ?? 'draft', existing: stored, secretCipher: null, now: deps.clock.now() });
const report = await deps.providers[target.input.kind].probe(providerAccount(draft, secret));
return redactReport(report, secret);
}
const record = await requireAccount(target.accountId);
const secret = openSecret(record);
const report = redactReport(await deps.providers[record.kind].probe(providerAccount(record, secret)), secret);
// wecom 的地址由探测回填;imap-smtp 的地址是人填的,不改
if (record.kind === 'wecom' && report.address !== undefined && report.address !== record.address) {
await deps.accounts.savePoll(record.id, { address: report.address });
}
return report;
},
async pollDue(now) {
const due = await deps.accounts.listDue(now);
// 账号间并行,单账号内串行;一个账号的异常只记在它自己头上
const settled = await Promise.allSettled(due.map((record) => pollAccount(record)));
return settled.map((r, i) =>
r.status === 'fulfilled'
? r.value
: { accountId: due[i]?.id ?? '?', pulled: 0, stored: 0, dispatched: 0, more: false, error: errorText(r.reason) },
);
},
async pollNow(accountId) {
const record = await requireAccount(accountId);
if (!record.enabled) throw new MailAccountDisabledError(accountId);
return pollAccount(record);
},
subscribe(name, handler) {
if (subscribers.has(name)) throw new MailSubscriberTakenError(name);
subscribers.set(name, handler);
return () => {
subscribers.delete(name);
};
},
async listInbound(query) {
const rows = await deps.inbound.list({ accountId: query.accountId, limit: limitOf(query) });
return rows.map((r) => summaryOf(r.record, r.deliveries));
},
async getInbound(mailId) {
const hit = await deps.inbound.getMeta(mailId);
if (hit === undefined) throw new MailInboundNotFoundError(mailId);
return detailOf(hit.record, hit.deliveries);
},
async attachment(mailId, index) {
// 附件在 sys.upload 里,这里只要那一行的 meta —— 原件字节不必跟着出来
const hit = await deps.inbound.getMeta(mailId);
if (hit === undefined) throw new MailInboundNotFoundError(mailId);
const meta = hit.record.attachments.find((a) => a.index === index);
if (meta === undefined || meta.ref === null) throw new MailAttachmentNotFoundError(mailId, index);
const file = await deps.attachments.get(meta.ref);
if (file === undefined) throw new MailAttachmentNotFoundError(mailId, index);
return { name: meta.name, contentType: meta.contentType, bytes: file.bytes };
},
async raw(mailId) {
// 全仓唯一要 raw 字节的一条路
const hit = await deps.inbound.get(mailId);
if (hit === undefined) throw new MailInboundNotFoundError(mailId);
if (hit.record.raw === null) throw new MailAttachmentNotFoundError(mailId, -1);
return { name: `${safeFileName(hit.record.messageId)}.eml`, bytes: hit.record.raw };
},
async redeliver(mailId, subscriber) {
const hit = await deps.inbound.getMeta(mailId);
if (hit === undefined) throw new MailInboundNotFoundError(mailId);
if (subscriber !== undefined && !subscribers.has(subscriber)) throw new MailSubscriberUnknownError(subscriber);
const owner = await deps.accounts.get(hit.record.accountId);
const deliveries = await dispatch(hit.record, { accountId: hit.record.accountId, channelId: owner?.channelId ?? null }, subscriber, {
reclaimStale: true,
});
deps.notify.changed('inbound');
return deliveries.map(deliveryViewOf);
},
async send(input) {
const account = pickAccount(await deps.accounts.list(), { accountId: input.accountId, from: input.from });
// 幂等键:放行重放不发两封
const prior = input.idempotencyKey === undefined ? undefined : await deps.outbound.byIdempotencyKey(input.idempotencyKey);
if (prior !== undefined && prior.state === 'sent') return resultOf(prior);
/*
* **同键不发两封**。`sending` 有两种局面而库里长得一样:正在飞 / 上次崩在半路 —— 用时限分开。
* 没超时的:不发,如实回「没发出去」并说清缘由(调用方再试一次即可;第一封落地后同键就短路成 sent 了)。
* 超时的、以及 `failed` 的:复用那一行重发。
*/
if (prior !== undefined && prior.state === 'sending') {
const ageMs = deps.clock.now().getTime() - prior.createdAt.getTime();
if (ageMs < SEND_STALE_MS) {
return {
outboundId: prior.id,
accountId: prior.accountId,
messageId: prior.messageId,
state: 'failed',
error: `同一幂等键的上一封仍在发送中(${Math.round(ageMs / 1000)}s),本次未重复发送`,
};
}
}
const files: ParsedAttachment[] = [];
for (const a of input.attachments) {
if (a.dataBase64 !== undefined) {
files.push({ name: a.name, contentType: a.contentType, bytes: new Uint8Array(Buffer.from(a.dataBase64, 'base64')) });
} else if (a.uploadRef !== undefined) {
const got = await deps.attachments.get(a.uploadRef);
if (got === undefined) throw new MailSendInvalidError([`附件不存在:${a.uploadRef}`]);
files.push({ name: a.name, contentType: a.contentType === 'application/octet-stream' ? got.contentType : a.contentType, bytes: got.bytes });
} else {
throw new MailSendInvalidError([`附件 ${a.name} 既没给字节也没给 uploadRef`]);
}
}
const problems = validateSend(input, account.kind, files.map((f) => f.bytes.byteLength));
if (problems.length > 0) throw new MailSendInvalidError(problems);
const secret = openSecret(account);
const now = deps.clock.now();
const id = prior?.id ?? deps.ids.next();
const messageId = prior?.messageId ?? messageIdFor(id, account.address);
if (prior !== undefined) {
/*
* **复用旧行之前先抢**:上面那一段只判断了「这一行看着可以复用」,而两个后端连同一个库时
* 两台会同时判出同一个结论 —— 都发,就是两封真邮件。抢是一条条件更新(见 `claimForResend`),
* 抢不到说明另一路已经接管,这一次如实回「没发出去」。
*/
const claimed = await deps.outbound.claimForResend(prior.id, { staleBefore: new Date(now.getTime() - SEND_STALE_MS), now });
if (!claimed) {
return {
outboundId: prior.id,
accountId: prior.accountId,
messageId: prior.messageId,
state: 'failed',
error: '同一幂等键的上一封正被另一路重发,本次未重复发送',
};
}
}
if (prior === undefined) {
const record: OutboundRecord = {
id,
accountId: account.id,
messageId,
idempotencyKey: input.idempotencyKey ?? null,
to: [...input.to],
cc: [...input.cc],
bcc: [...input.bcc],
subject: input.subject,
text: input.text ?? null,
html: input.html ?? null,
inReplyTo: input.inReplyTo ?? null,
references: [...input.references],
attachments: files.map((f, i) => ({ name: f.name, contentType: f.contentType, size: f.bytes.byteLength, ref: input.attachments[i]?.uploadRef ?? null })),
state: 'sending',
error: null,
providerRef: null,
requestedBy: { ...input.requestedBy },
createdAt: now,
sentAt: null,
};
await deps.outbound.insert(record);
}
try {
const sent = await deps.providers[account.kind].send(providerAccount(account, secret), {
messageId,
from: { address: account.address, name: account.displayName },
to: input.to,
cc: input.cc,
bcc: input.bcc,
subject: input.subject,
...(input.text === undefined ? {} : { text: input.text }),
...(input.html === undefined ? {} : { html: input.html }),
...(input.inReplyTo === undefined ? {} : { inReplyTo: input.inReplyTo }),
references: input.references,
attachments: files,
});
await deps.outbound.setState(id, 'sent', { sentAt: deps.clock.now(), ...(sent.providerRef === undefined ? {} : { providerRef: sent.providerRef }) });
deps.notify.changed('outbound');
return { outboundId: id, accountId: account.id, messageId, state: 'sent', ...(sent.providerRef === undefined ? {} : { providerRef: sent.providerRef }) };
} catch (error) {
const text = redactWith(secret)(errorText(error));
await deps.outbound.setState(id, 'failed', { error: text });
deps.notify.changed('outbound');
return { outboundId: id, accountId: account.id, messageId, state: 'failed', error: text };
}
},
async listOutbound(query) {
const rows = await deps.outbound.list({ accountId: query.accountId, limit: limitOf(query) });
return rows.map((r) => ({
id: r.id,
accountId: r.accountId,
messageId: r.messageId,
to: [...r.to],
cc: [...r.cc],
bcc: [...r.bcc],
subject: r.subject,
state: r.state,
error: r.error,
providerRef: r.providerRef,
requestedBy: r.requestedBy,
createdAt: r.createdAt.toISOString(),
sentAt: r.sentAt === null ? null : r.sentAt.toISOString(),
}));
},
};
}

Some files were not shown because too many files have changed in this diff Show More