feat(mail): harden and align shortcut workflows

This commit is contained in:
Dennis committed 2026-08-19 14:37:11 +08:00
1 parent 8b4453adf9
commit 725e60f07c
23 files changed
+1894 -596

No files matched your search

@@ -0,0 +1,171 @@
# Mail Shortcut 下游业务能力需求规格
> 日期:2026-08-18
> DWS 基线:`effde762277ad717a246e38b237a8297fa49aab7`
> 对比基线:lark-cli 1.0.87
> 范围:Shortcut only;不修改 `skills/multi` 或 `skills/mono`
> 发布属性:仓库安全版本;不包含真实邮箱、人员、组织、邮件内容、资源 ID 或请求标识。
## 1. 执行摘要
本轮把 Mail 从 10 个列表/搜索入口扩展并收紧为 18 个公开 Shortcut:新增 `+triage`、`+message`、`+messages`、`+thread`、`+draft-create`、`+draft-edit`、`+template-create`、`+template-update`;现有列表和 smart 入口全部改为严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result。真实 E2E 已覆盖已知非空、保证零命中、单封/多封/会话身份绑定、草稿与模板写后读回、联系人和模板临时 fixture 清理。
仍不能诚实对齐的任务集中在发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口补足可验证合同。
| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 下游 Owner | 解锁的 Shortcut |
|---|---|---|---|---|---|---|
| `DS-Mail-001` | P0 | contract insufficient | 发信/发送草稿并确认最终投递 | partial | Mail service / adapter | `+send`、`+draft-send` |
| `DS-Mail-002` | P0 | missing capability | 回复、回复全部、转发默认保存草稿 | partial | Mail service | `+reply`、`+reply-all`、`+forward` |
| `DS-Mail-003` | P0 | contract insufficient | 批量修改、移动、软删除邮件 | partial | Mail service / adapter | `+message-modify`、`+message-trash` |
| `DS-Mail-004` | P1 | missing capability | 处理已读回执与邮箱签名 | unavailable | Mail service | `+send-receipt`、`+decline-receipt`、`+signature` |
| `DS-Mail-005` | P1 | missing capability | 持续监听新邮件 | unavailable | Event + Mail service | `+watch` |
| `DS-Mail-006` | P1 | contract insufficient | 带附件/内联图片的模板创建更新 | partial | Mail + Drive adapters | 完整 `+template-create/update` |
| `DS-Mail-007` | P1 | adapter defect | 创建联系人并取得稳定身份 | blocked | Mail adapter | `+contact-create/update/delete` |
| `DS-Mail-008` | P1 | adapter defect | 一致的成功、空结果与分页合同 | partial | Mail adapter | 全部 list/search Shortcut |
| `DS-Mail-009` | P1 | tenant-or-fixture | 安全验证发送、回执、分享和监听 | blocked | Product QA / tenant admin | 全部高影响 Mail Shortcut |
## 2. 用户任务与能力缺口总览
| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 |
|---|---|---|---|---|---|
| 浏览/筛选摘要 | `+triage`、`+search-mail`、`+unread-mail`、`+recent-mail` | `+triage` | covered | 无 | 公开,严格分页 |
| 读取一封、多封、会话 | `+message`、`+messages`、`+thread` | 同名入口 | covered | 无 | 公开,精确 ID 读回 |
| 新建/编辑草稿 | `+draft-create`、`+draft-edit` | 同名入口 | covered | 无 | 公开,确认 + 写后读回 |
| 创建/更新基础模板 | `+template-create`、`+template-update` | 同名入口 | partial | 附件与 HTML 资源事务缺失 | 公开核心字段;不承诺附件对齐 |
| 发送新邮件/已有草稿 | 无公开 Shortcut;存在 raw send | `+send`、`+draft-send` | partial | 终态、逐项结果、幂等不足 | 保持 raw,不宣称对齐 |
| 回复/回复全部/转发 | 无公开 Shortcut;raw 路径会立即发送 | `+reply`、`+reply-all`、`+forward` | partial | 缺少默认草稿与邮件头保真合同 | 保持 raw,不宣称对齐 |
| 修改/删除邮件 | 无公开 Shortcut;存在 raw batch route | `+message-modify`、`+message-trash` | partial | 无逐项 ledger 和严格终态 | 保持 raw,不宣称对齐 |
| 发送/拒绝已读回执 | 无 | `+send-receipt`、`+decline-receipt` | unavailable | 专用业务接口与标签合同缺失 | 明确不可用 |
| 邮箱签名 | 无 | `+signature` | unavailable | 签名读取接口缺失 | 明确不可用 |
| 分享邮件到聊天 | raw 高风险入口 | `+share-to-chat` | partial | 缺安全 fixture、逐目标结果与读回 | 不公开 Shortcut |
| HTML lint | 无 | `+lint-html` | unavailable | 缺统一邮件 HTML 规则包 | 下游或本地规则能力需求 |
| 监听新邮件 | 无公开 Mail Shortcut | `+watch` | unavailable | 订阅生命周期和安全事件合同不足 | 不公开 Shortcut |
| 文件夹/标签/联系人/企业邮箱用户 | 现有严格列表与搜索入口 | 无同名任务入口 | DWS extra | 非对齐项 | 作为 DWS 原生增量保留 |
## 3. 下游需求明细
### `DS-Mail-001` — 可验证的发送生命周期
- 用户任务:发送新邮件或一个/多个草稿,并知道每一封最终是成功、失败、部分成功还是状态未知。
- 当前证据:raw 发送可返回业务 success 或发送标识,但不能统一证明最终投递;批量草稿发送没有逐项 ledger、请求顺序、未知提交和安全重试合同。
- 所需接口合同:
- 创建/发送必须返回稳定 `messageId` 与 `internetMessageId`,并明确 `accepted/pending/sent/partial_failure/failure/unknown`。
- 提供按同一身份查询发送状态的接口;状态必须绑定请求邮件与收件人集合。
- 批量发送返回逐项结果,任何一项失败时整体不得退出 0 冒充全成功。
- 支持幂等键,或明确 unknown commit 不可自动重试。
- 失败错误区分参数、权限、风控、限流、收件人拒收和提交未知。
- 验收:安全自发自收 fixture 完成 draft-create → exact get → send → 状态终态 → sent-folder exact read;批量中注入一项失败,验证 ledger 与非零整体结果;清理无测试草稿残留。
### `DS-Mail-002` — 回复/转发的草稿优先与 MIME 保真
- 用户任务:回复、回复全部或转发一封邮件,默认保存草稿,只有再次确认才发送。
- 当前证据:DWS raw route 会创建回复/转发草稿后立即发送,无法对齐 Lark 的默认草稿语义;上游也无法证明 `In-Reply-To`、`References`、原始引用块和收件人集合正确。
- 所需接口合同:
- 独立 `create_reply_draft`、`create_reply_all_draft`、`create_forward_draft`,返回稳定草稿 ID,不隐式发送。
- 服务端生成并可读回线程关系头、回复全部去重后的 To/CC、转发引用块和附件继承结果。
- 发送必须复用 `DS-Mail-001` 的确认、终态和幂等合同。
- 验收:用隔离自发邮件分别创建三类草稿,精确 ID 读回核对父邮件、参与人集合和引用语义;未确认时远程发送调用为 0;确认发送后状态终态可验证。
### `DS-Mail-003` — 邮件修改、移动和删除的逐项终态
- 用户任务:批量标记已读/未读、增删标签、移动文件夹、软删除邮件。
- 当前证据:raw batch route 多数只给聚合 success;删除后邮件仍可能可读,无法区分“移入已删除文件夹”“永久删除”“延迟可见”或“未生效”。
- 所需接口合同:
- 每个输入 messageId 返回 `applied/already_applied/failed/unknown` 与稳定原因码。
- 修改/移动后详情或摘要必须可读回 `isRead/tags/folderId`;删除返回明确 tombstone 或 folder transition。
- 软删除和永久删除使用不同操作,危险级别与确认要求可声明。
- 任何部分失败整体 outcome 为 `partial_failure` 且进程非零。
- 验收:创建隔离邮件,执行 mark-unread/read、标签增删、移动与软删除,每步同 ID 读回;错误 ID 与合法 ID 混合时逐项 ledger 完整且整体非零。
### `DS-Mail-004` — 已读回执与签名
- 用户任务:识别邮件是否请求回执;确认后发送标准回执,或拒绝并清除提示;列出和查看默认签名。
- 当前证据:现有 Mail 接口没有稳定暴露回执请求标签、专用发送/拒绝操作或签名读取资源,上游无法安全组合普通回复替代。
- 所需接口合同:
- 消息详情公开稳定回执请求状态和请求者身份类型。
- 专用 send/decline receipt 操作,幂等且返回状态;正文由服务端生成,不能让上游伪造。
- 签名列表/详情返回稳定 ID、默认发送场景、HTML/文本内容和敏感字段标注。
- 验收:预置请求回执邮件,未确认零写调用;发送/拒绝后状态读回且重复调用幂等;签名已知非空与合法空均可证明。
### `DS-Mail-005` — 新邮件监听的订阅生命周期
- 用户任务:在限定时间内监听新邮件,得到稳定、可恢复、可去重的事件流。
- 当前证据:通用事件基础设施不能证明 Mail scope、订阅状态、ready marker、断线续传和消息读取权限形成完整任务链。
- 所需接口合同:订阅/查询/退订;明确 user/bot 身份、scope 和租户开关;ready marker;事件 `eventId/messageId/mailbox/time`;断线 cursor、去重和界限参数;心跳不冒充业务事件。
- 验收:隔离邮箱订阅后注入一封测试邮件,只收到一次并能以 messageId 精确读取;超时、权限缺失、断线重连和退订后零事件均有确定结果。
### `DS-Mail-006` — 模板附件与内联图片事务
- 用户任务:创建或更新含普通附件、内联图片和 HTML 的模板,同时保留未修改 MIME 结构。
- 当前证据:本轮只对齐名称、主题、正文核心字段;现有多步上传缺少模板级事务、附件稳定 ID、失败回滚和更新时的结构保真证明。
- 所需接口合同:创建/更新草稿会话、附件上传会话、content-id 映射、提交/取消;返回逐附件 ledger;更新提供版本或 etag,避免 last-write-wins 覆盖;失败可回滚且无孤儿文件。
- 验收:普通附件和内联图片各一,创建后按模板 ID 读取附件 ID/名称/大小/content-id;更新正文不丢附件;中途失败自动取消并证明零孤儿资源。
### `DS-Mail-007` — 联系人写操作的稳定身份
- 用户任务:创建、更新、删除个人邮件联系人并验证精确对象。
- 当前证据:真实 create 返回 `success=true` 但没有 contactId;上游只能用随机显示名再扫列表定位,无法用于一般用户输入,因为名称/邮箱可能重复。
- 所需接口合同:create 返回稳定 contactId;get-by-id;update/delete 返回同 ID 与版本;列表支持 exact email 或 ID filter;重复联系人规则明确。
- 验收:创建回执直接得到 ID,get-by-id 精确核对,更新同 ID,删除后 not-found/tombstone;重复邮箱和同名联系人有稳定结果而非猜测。
### `DS-Mail-008` — 统一成功、空结果与分页协议
- 用户任务:可靠地区分“确实没有结果”“还有下一页”“服务异常或响应漂移”。
- 当前证据:同一产品的 success 同时出现布尔和字符串;hasMore 也出现两种编码;搜索终页用 `$`,部分列表用空串;零命中邮件会返回 `total=0` 加一个只有空收件人字段的占位对象。
- 所需接口合同:
- success 与 hasMore 统一为布尔;所有列表显式数组,合法空只返回 `[]`。
- 统一 `nextCursor` 与 `endpointExhausted`;终页不使用业务哨兵对象或魔法值。
- 每项稳定 ID 必填;total 使用整数;服务错误必须 `success=false` 和稳定错误码。
- 保留兼容期,但提供 capability/version 让上游安全切换。
- 验收:每个列表/搜索执行已知非空、保证零命中、坏 item、缺集合、错型、hasMore 无游标、重复游标;只有显式合法空成功。
### `DS-Mail-009` — 安全租户与真实 E2E fixture
- 用户任务:在不触达真实业务收件人和内容的前提下验证所有高影响 Mail Shortcut。
- 所需 fixture:隔离自发自收邮箱、可控第二收件人、回执请求邮件、可分享的测试聊天、安全事件订阅、测试签名、可回收附件;所有资源用随机无业务含义标记并有自动清理。
- 权限:最小 Mail read/write/event、Drive attachment、IM share scopes 分离;可测试 user/bot 差异和缺权限错误。
- 验收:stdout 只输出 PASS 标签与聚合计数;原始 JSON 只在临时目录;finally 清理;远端零测试草稿/模板/联系人/邮件/订阅残留;仓库和历史扫描无身份数据。
## 4. Lark 对齐与平台差异
| Lark 用户任务 | 可精确对齐 | 平台差异 | DWS 推荐结论 |
|---|---|---|---|
| `+message` / `+messages` / `+thread` / `+triage` | yes | DWS 额外自动解析邮箱和收件箱,并严格发布完整性 | 已公开 |
| `+draft-create` / `+draft-edit` | yes(核心正文) | 附件编辑仍依赖下游事务 | 已公开,附件不宣称完整对齐 |
| `+template-create` / `+template-update` | partial | 核心字段已读回;附件/内联资源/并发更新不足 | 已公开核心能力,差异写入合同 |
| `+send` / `+draft-send` | no | DWS raw 偏立即发送且缺统一终态/逐项 ledger | 暂不公开 Shortcut |
| `+reply` / `+reply-all` / `+forward` | no | DWS raw 会立即发送,Lark 默认保存草稿 | 暂不公开 Shortcut |
| `+message-modify` / `+message-trash` | no | 聚合 success 不足以证明逐项终态 | 暂不公开 Shortcut |
| `+send-receipt` / `+decline-receipt` | no | 缺专用接口和可验证标签 | platform unavailable |
| `+signature` | no | 缺签名读取资源 | platform unavailable |
| `+watch` | no | 缺完整订阅生命周期与安全 fixture | fixture + capability blocked |
| `+share-to-chat` | partial | raw 可调用但缺逐目标验证和安全 fixture | 保持 raw |
| `+lint-html` | no | DWS 未提供统一规则包 | downstream/local capability needed |
## 5. 超越 Lark 的产品机会
| 产品原生能力 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 |
|---|---|---|---|
| 文件夹、标签与联系人目录 | `+organize`:规则化移动、标记与标签组合 | 逐项 ledger、写后读回、补偿恢复 | P1 |
| 收信规则、白名单、黑名单、自动回复 | `+inbox-policy-audit` | 只读汇总优先;写操作强确认和版本化 | P2 |
| 邮箱日历 | `+mail-calendar-conflicts` | 与主 Calendar 的 ownership boundary 明确,禁止双写 | P2 |
| 发送状态与召回 | `+delivery-audit` | 终态、收件人粒度、召回结果和不可逆提示 | P1 |
| 附件导出与分享 | `+archive-message` | 精确 messageId、原子本地写入、敏感路径与清理 | P2 |
## 6. 无需下游变更的上游修复
| Shortcut | 上游根因 | 已完成修复 | 回归证据 |
|---|---|---|---|
| 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID | 单测矩阵 + live 非空/空结果 |
| `+search-mail` / `+unread-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | exact live 零命中 |
| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | exact live 非空和随机零命中 |
| `+contact-list` | 字符串 `hasMore` 会造成漂移或误判 | 只接受已观测布尔/精确字符串 true/false | 临时联系人 create→list→delete |
| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | exact live 同 ID |
| 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对 | exact live create/update/readback/cleanup |
## 7. 安全与脱敏声明
- 本文不含用户、组织、租户、profile、邮箱、人员姓名、邮件/会话/模板/联系人/聊天真实 ID。
- 本文不含邮件主题正文、收发件人、trace/request ID、token、签名 URL、电话或真实业务时间。
- 真实 E2E 原始响应仅在仓库外临时目录解析;普通输出只保留能力标签、计数和布尔断言。
- 临时草稿已移出草稿箱,临时模板和联系人已删除并复查;最终提交前仍需扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。
+132 -22
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-08-18T12:08:16.875520",
"count": 433,
"generated_at": "2026-08-18T13:37:43.285113",
"count": 441,
"results": [
{
"suite": "semantic",
@@ -3342,74 +3342,184 @@
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "mail",
"command": "+contact-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格读取 contacts 数组、稳定联系人 ID,并兼容已观测的字符串 hasMore;已完成创建到列表到清理闭环。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "mail",
"command": "+draft-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "对齐 Lark 新建草稿;确认后创建但不发送,要求稳定 messageId 并按同一 ID 读回核对主题和正文。",
"availability": "available"
},
{
"suite": "semantic",
"service": "mail",
"command": "+draft-edit",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "对齐 Lark 草稿编辑;至少一个变化字段,严格写回执后按原 messageId 读回核对。",
"availability": "available"
},
{
"suite": "semantic",
"service": "mail",
"command": "+find-mail-user",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "面向联系人解析的精简用户搜索;修复零命中被误报为调用失败,并严格验证用户数组、身份和分页。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "mail",
"command": "+folder-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "只接受显式 folders 数组及稳定 folder ID;缺字段、错型和坏元素不再退化为空列表。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "mail",
"command": "+message",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "对齐 Lark 单封完整读入口;自动解析邮箱,要求顶层 message 非空且返回 ID 与请求 messageId 精确一致。",
"availability": "available"
},
{
"suite": "semantic",
"service": "mail",
"command": "+messages",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "对齐 Lark 多封完整读入口;最多 100 个 ID,保持请求顺序,任何单封缺失、错型或身份不一致都会使整次调用失败。",
"availability": "available"
},
{
"suite": "semantic",
"service": "mail",
"command": "+recent-mail",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "自动解析邮箱和收件箱,严格读取 result.conversations、稳定会话 ID 与 hasMore/nextCursor 证据。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "mail",
"command": "+search-mail",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "用 KQL 搜索邮件摘要;只接受已观测 messages 数组、稳定 messageId 和完整分页证据,窄化支持 Mail 专属 total=0 占位哨兵。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "mail",
"command": "+tag-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "只接受显式 tags 数组及稳定 tag ID;真实非空标签 fixture 已验证。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "mail",
"command": "+template-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "对齐 Lark 模板创建的核心字段;取得稳定模板 ID 后读回核对名称、主题和正文,附件能力另列下游缺口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "mail",
"command": "+template-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格读取 templates 数组、稳定模板 ID 和 nextCursor;已用临时模板证明非空、删除后证明零命中。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "mail",
"command": "+template-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "对齐 Lark 草稿模板更新;严格业务成功后按原模板 ID 读回核对全部请求变更。",
"availability": "available"
},
{
"suite": "semantic",
"service": "mail",
"command": "+thread",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "对齐 Lark 完整会话读取;自动解析邮箱并要求 conversation.id 与请求精确一致。",
"availability": "available"
},
{
"suite": "semantic",
"service": "mail",
"command": "+thread-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格读取 result.conversations,要求每项稳定 ID,并发布 hasMore/nextCursor 完整性。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "mail",
"command": "+triage",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "对齐 Lark triage 任务入口,并超过其显式邮箱要求:可自动解析当前邮箱与收件箱,严格处理邮件搜索的终止游标和零命中哨兵。",
"availability": "available"
},
{
"suite": "semantic",
"service": "mail",
"command": "+unread-mail",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "自动解析邮箱并以 isRead:false 搜索未读邮件;不修改已读状态,严格区分合法零命中与坏消息对象。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "mail",
"command": "+user-search",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "搜索企业邮箱用户并保留 hasMore/nextCursor;显式空数组合法,坏用户或缺稳定 ID 失败。",
"availability": "available"
},
{
"suite": "semantic",
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 432
publicShortcutCount = 440
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 438
schemaPublishedShortcutCount = 446
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 432
publiclyDeliveredShortcutCount = 440
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -0,0 +1,79 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageMailSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_mail.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
if source.Service != "mail" {
t.Fatalf("service = %q", source.Service)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "mail" {
registered[item.Command] = item
}
}
if len(registered) != 18 || len(source.Shortcuts) != 18 {
t.Fatalf("registered/catalog = %d/%d, want 18/18", len(registered), len(source.Shortcuts))
}
var missing, stale []string
public := 0
for command, item := range registered {
record, ok := source.Shortcuts[command]
if !ok {
missing = append(missing, command)
continue
}
availability := record.Availability
if availability == "" {
availability = source.Availability
}
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
t.Errorf("%s: semantic review facts drifted", command)
}
if item.Risk != record.Risk {
t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk)
}
if record.Public {
public++
if availability != shortcut.AvailabilityAvailable || item.Hidden || item.Availability != shortcut.AvailabilityAvailable {
t.Errorf("%s: public available shortcut is not executable/visible", command)
}
if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s: public shortcut lacks contract/safety/result/unified output", command)
}
}
}
for command := range source.Shortcuts {
if _, ok := registered[command]; !ok {
stale = append(stale, command)
}
}
sort.Strings(missing)
sort.Strings(stale)
if len(missing) > 0 || len(stale) > 0 {
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
}
if public != 18 {
t.Fatalf("public mail shortcuts = %d, want 18", public)
}
}
+301
View File
@@ -0,0 +1,301 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package mail
import (
"encoding/json"
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const mailCompositeReason = "Reviewed Mail Shortcut composite: the executable CLI owns strict response validation, pagination evidence, stable-identity checks, output projection, and confirmation; no single MCP interface represents the complete command contract."
func mailCollectionResult(collection, description string) *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件业务记录","additionalProperties":true}},"complete":{"type":"boolean","description":"服务端分页证据是否证明结果完整"},"nextCursor":{"type":"string","description":"结果未完整时的下一页游标"}},"required":["count",%q,"complete"],"additionalProperties":false}`,
description, collection, description, collection,
)),
}
}
func mailObjectResult(description string) *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验且身份匹配的邮件业务对象","additionalProperties":true}},"required":["value"],"additionalProperties":false}`,
description,
)),
SensitivePaths: []string{"value.body", "value.markdownBody", "value.subject", "value.from", "value.toRecipients", "value.ccRecipients", "value.bccRecipients"},
}
}
func mailReadSafety() contract.SafetySpec {
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
}
func mailWriteSafety(idempotency string) contract.SafetySpec {
return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: idempotency}
}
func mailReadContract(command, description, intent string, result *contract.ResultSpec, flags []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
cliPath := "mail " + command
return corecmd.ContractDecl{
Description: description,
Result: result,
Identity: contract.ToolIdentitySpec{
ProductID: "mail", Name: name, CanonicalPath: "mail." + name,
CLIPath: cliPath, PrimaryCLIPath: cliPath,
},
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: mailCompositeReason},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{intent},
AvoidWhen: []string{"只需摘要列表时使用 mail +triage 或 +recent-mail;需要原始响应时改用对应原子命令"},
Examples: examples,
},
Parameters: flags,
}
}
func mailWriteContract(command, description, intent string, flags []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
contractDecl := mailReadContract(command, description, intent, mailObjectResult(description), flags, examples...)
contractDecl.Selection.AvoidWhen = []string{"用户尚未确认写入内容或目标时不要执行;只需读取时使用对应只读 shortcut"}
return contractDecl
}
func mailResponseError(operation, reason, message string) error {
return apperrors.NewAPI(message,
apperrors.WithOperation(operation),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithRetryable(false),
apperrors.WithReason(reason),
)
}
// mailRequireSuccess accepts only the two success encodings observed from the
// Mail backend: boolean true and the exact string "true". Missing, false, or
// malformed status values fail closed.
func mailRequireSuccess(data map[string]any, operation string) error {
if len(data) == 0 {
return mailResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明调用成功或结果确实为空")
}
switch value := data["success"].(type) {
case bool:
if value {
return nil
}
case string:
if value == "true" {
return nil
}
}
if _, present := data["success"]; !present {
return mailResponseError(operation, "missing_success", "响应缺少 success 业务状态")
}
message := mailFirstString(data, "errorMessage", "errorMsg", "message", "error")
if message == "" {
message = "服务未明确返回成功状态"
}
return mailResponseError(operation, "remote_failure", message)
}
func mailRequireCollection(data map[string]any, operation, path string) ([]map[string]any, error) {
if err := mailRequireSuccess(data, operation); err != nil {
return nil, err
}
value, present := mailLookup(data, path)
if !present {
return nil, mailResponseError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path))
}
raw, ok := value.([]any)
if !ok {
return nil, mailResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value))
}
items := make([]map[string]any, 0, len(raw))
for index, item := range raw {
object, ok := item.(map[string]any)
if !ok || len(object) == 0 {
return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index))
}
items = append(items, object)
}
return items, nil
}
func mailRequireObject(data map[string]any, operation, path string) (map[string]any, error) {
if err := mailRequireSuccess(data, operation); err != nil {
return nil, err
}
value, present := mailLookup(data, path)
if !present || value == nil {
return nil, mailResponseError(operation, "missing_result", fmt.Sprintf("成功响应缺少非空 %s 对象", path))
}
object, ok := value.(map[string]any)
if !ok || len(object) == 0 {
return nil, mailResponseError(operation, "malformed_result", fmt.Sprintf("响应 %s 应为非空对象,实际为 %T", path, value))
}
return object, nil
}
func mailRequireIdentity(object map[string]any, operation, expected string, keys ...string) error {
actual := mailFirstString(object, keys...)
if actual == "" {
return mailResponseError(operation, "missing_stable_id", "业务对象缺少稳定 ID")
}
if expected != "" && actual != expected {
return mailResponseError(operation, "identity_mismatch", "业务对象 ID 与请求目标不一致")
}
return nil
}
func mailValidateRows(items []map[string]any, operation string, identityKeys ...string) error {
for index, item := range items {
if mailFirstString(item, identityKeys...) == "" {
return mailResponseError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index))
}
}
return nil
}
func mailProjectCollection(data map[string]any, operation, path string, identityKeys []string, fields map[string][]string) ([]map[string]any, error) {
items, err := mailRequireCollection(data, operation, path)
if err != nil {
return nil, err
}
if err := mailValidateRows(items, operation, identityKeys...); err != nil {
return nil, err
}
projected := make([]map[string]any, 0, len(items))
for _, item := range items {
row := make(map[string]any, len(fields))
for outputName, candidates := range fields {
for _, candidate := range candidates {
if value, present := item[candidate]; present && value != nil {
row[outputName] = value
break
}
}
}
projected = append(projected, row)
}
return projected, nil
}
func mailPage(data map[string]any, operation, prefix string) (bool, string, error) {
container := data
if prefix != "" {
value, present := mailLookup(data, prefix)
if !present {
return false, "", mailResponseError(operation, "missing_pagination", "分页响应缺少已审核的分页对象")
}
var ok bool
container, ok = value.(map[string]any)
if !ok {
return false, "", mailResponseError(operation, "malformed_pagination", "分页容器不是对象")
}
}
nextValue, nextPresent := container["nextCursor"]
next, nextOK := nextValue.(string)
if !nextPresent || !nextOK {
return false, "", mailResponseError(operation, "missing_pagination", "响应缺少字符串 nextCursor,无法证明结果是否完整")
}
if raw, present := container["hasMore"]; present {
hasMore, ok := mailBool(raw)
if !ok {
return false, "", mailResponseError(operation, "malformed_pagination", "hasMore 应为布尔值")
}
terminal := strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$"
if hasMore && terminal {
return false, "", mailResponseError(operation, "missing_next_cursor", "hasMore=true 但 nextCursor 为空")
}
if !hasMore && !terminal {
return false, "", mailResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 非空")
}
if !hasMore {
return true, "", nil
}
return false, next, nil
}
if strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" {
return true, "", nil
}
return false, next, nil
}
func mailBool(value any) (bool, bool) {
switch typed := value.(type) {
case bool:
return typed, true
case string:
if typed == "true" {
return true, true
}
if typed == "false" {
return false, true
}
}
return false, false
}
func mailLookup(object map[string]any, path string) (any, bool) {
var current any = object
for _, segment := range strings.Split(path, ".") {
mapped, ok := current.(map[string]any)
if !ok {
return nil, false
}
current, ok = mapped[segment]
if !ok {
return nil, false
}
}
return current, true
}
func mailFirstString(object map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func mailCollectionPayload(collection string, items []map[string]any, complete bool, next string) map[string]any {
payload := map[string]any{"count": len(items), collection: items, "complete": complete}
if !complete {
payload["nextCursor"] = next
}
return payload
}
func hardenPublicMailContracts() {
collections := []struct {
declaration *shortcut.Shortcut
collection string
description string
}{
{&ThreadList, "threads", "严格校验的邮件会话列表"},
{&FolderList, "folders", "严格校验的邮箱文件夹列表"},
{&TagList, "tags", "严格校验的邮件标签列表"},
{&UserSearch, "users", "严格校验的企业邮箱用户搜索结果"},
{&TemplateList, "templates", "严格校验的邮件模板列表"},
{&ContactList, "contacts", "严格校验的邮件联系人列表"},
}
for _, item := range collections {
item.declaration.OutputRollout = output.RolloutUnifiedActive
item.declaration.Contract.Result = mailCollectionResult(item.collection, item.description)
item.declaration.Contract.Interface = &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: mailCompositeReason}
}
}
+59 -390
View File
@@ -110,76 +110,20 @@ var ThreadList = shortcut.Shortcut{
if err != nil {
return err
}
threads := threadListProject(data)
return rt.Output(map[string]any{"count": len(threads), "threads": threads})
threads, err := mailProjectCollection(data, "mail/list_mailbox_threads", "result.conversations", []string{"id"}, map[string][]string{
"conversationId": {"id"}, "subject": {"subject"}, "lastUpdated": {"lastModifiedDateTime"}, "isRead": {"isRead"},
})
if err != nil {
return err
}
complete, next, err := mailPage(data, "mail/list_mailbox_threads", "result")
if err != nil {
return err
}
return rt.Output(mailCollectionPayload("threads", threads, complete, next))
},
}
// threadListProject reshapes the raw list_mailbox_threads response into a clean
// {conversationId, subject, lastUpdated, isRead} thread list —
// clean output projection. Both the list container and per-item
// field names are probed defensively across candidate keys, so an empty/unknown
// shape yields an empty list rather than a crash or fabricated data.
func threadListProject(data map[string]any) []map[string]any {
raw := threadListResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
if v, ok := threadListFirst(m, "conversationId", "conversation_id", "id", "threadId"); ok {
row["conversationId"] = v
}
if v, ok := threadListFirst(m, "subject", "title", "topic"); ok {
row["subject"] = v
}
if v, ok := threadListFirst(m, "lastUpdated", "last_updated", "lastModifiedDateTime", "updateTime", "modifiedTime", "sentDateTime"); ok {
row["lastUpdated"] = v
}
if v, ok := threadListFirst(m, "isRead", "is_read", "read", "unread"); ok {
row["isRead"] = v
}
if len(row) > 0 {
out = append(out, row)
}
}
return out
}
// threadListResolveList locates the list payload inside the response, tolerating
// a bare top-level array container or nesting one level deeper.
func threadListResolveList(data map[string]any) []any {
for _, key := range []string{"result", "data", "list", "items", "threads", "conversations"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "threads", "conversations", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
}
// threadListFirst returns the first present candidate key's value.
func threadListFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
if v, ok := m[k]; ok {
return v, true
}
}
return nil, false
}
// Thread 根据会话 ID 获取会话详情。
// ThreadUpdate 修改单个邮件会话的状态或标签。
// ThreadBatchUpdate 批量修改邮件会话的状态或标签。
@@ -238,73 +182,16 @@ var FolderList = shortcut.Shortcut{
if err != nil {
return err
}
folders := folderListProject(data)
return rt.Output(map[string]any{"count": len(folders), "folders": folders})
folders, err := mailProjectCollection(data, "mail/list_folders", "folders", []string{"id"}, map[string][]string{
"id": {"id"}, "name": {"displayName"}, "parentId": {"parentFolderId"},
})
if err != nil {
return err
}
return rt.Output(mailCollectionPayload("folders", folders, true, ""))
},
}
// folderListProject reshapes the raw list_folders response into a clean
// {id, name, parentId} folder list — clean output projection. Both
// the list container and per-item field names are probed defensively across
// candidate keys, so an empty/unknown shape yields an empty list rather than a
// crash or fabricated data.
func folderListProject(data map[string]any) []map[string]any {
raw := folderListResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
if v, ok := folderListFirst(m, "id", "folderId", "folder_id"); ok {
row["id"] = v
}
if v, ok := folderListFirst(m, "name", "folderName", "folder_name", "displayName"); ok {
row["name"] = v
}
if v, ok := folderListFirst(m, "parentId", "parent_id", "parentFolderId"); ok {
row["parentId"] = v
}
if len(row) > 0 {
out = append(out, row)
}
}
return out
}
// folderListResolveList locates the list payload inside the response, tolerating
// a bare top-level array container or nesting one level deeper.
func folderListResolveList(data map[string]any) []any {
for _, key := range []string{"result", "data", "list", "items", "folders"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "folders", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
}
// folderListFirst returns the first present candidate key's value.
func folderListFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
if v, ok := m[k]; ok {
return v, true
}
}
return nil, false
}
// FolderCreate 创建邮件文件夹。
// FolderUpdate 更新邮件文件夹名称。
// FolderDelete 删除邮件文件夹。
@@ -356,73 +243,16 @@ var TagList = shortcut.Shortcut{
if err != nil {
return err
}
tags := tagListProject(data)
return rt.Output(map[string]any{"count": len(tags), "tags": tags})
tags, err := mailProjectCollection(data, "mail/list_tags", "tags", []string{"id"}, map[string][]string{
"id": {"id"}, "name": {"name"}, "parentId": {"parentId"},
})
if err != nil {
return err
}
return rt.Output(mailCollectionPayload("tags", tags, true, ""))
},
}
// tagListProject reshapes the raw list_tags response into a clean
// {id, name, parentId} tag list — clean output projection. Both the
// list container and per-item field names are probed defensively across
// candidate keys, so an empty/unknown shape yields an empty list rather than a
// crash or fabricated data.
func tagListProject(data map[string]any) []map[string]any {
raw := tagListResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
if v, ok := tagListFirst(m, "id", "tagId", "tag_id"); ok {
row["id"] = v
}
if v, ok := tagListFirst(m, "name", "tagName", "tag_name", "displayName"); ok {
row["name"] = v
}
if v, ok := tagListFirst(m, "parentId", "parent_id"); ok {
row["parentId"] = v
}
if len(row) > 0 {
out = append(out, row)
}
}
return out
}
// tagListResolveList locates the list payload inside the response, tolerating a
// bare top-level array container or nesting one level deeper.
func tagListResolveList(data map[string]any) []any {
for _, key := range []string{"result", "data", "list", "items", "tags"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "tags", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
}
// tagListFirst returns the first present candidate key's value.
func tagListFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
if v, ok := m[k]; ok {
return v, true
}
}
return nil, false
}
// TagCreate 创建邮件标签。
// TagUpdate 更新邮件标签名称。
// TagDelete 删除邮件标签。
@@ -499,76 +329,20 @@ var UserSearch = shortcut.Shortcut{
if err != nil {
return err
}
users := userSearchProject(data)
return rt.Output(map[string]any{"count": len(users), "users": users})
users, err := mailProjectCollection(data, "mail/search_mail_users", "users", []string{"id", "email"}, map[string][]string{
"name": {"name"}, "email": {"email"}, "employeeNo": {"employeeNo"}, "userId": {"id"},
})
if err != nil {
return err
}
complete, next, err := mailPage(data, "mail/search_mail_users", "")
if err != nil {
return err
}
return rt.Output(mailCollectionPayload("users", users, complete, next))
},
}
// userSearchProject reshapes the raw search_mail_users response into a clean
// {name, email, employeeNo, userId} user list — clean output projection.
// Both the list container and per-item field names are probed defensively
// across candidate keys, so an empty/unknown shape yields an empty list rather
// than a crash or fabricated data.
func userSearchProject(data map[string]any) []map[string]any {
raw := userSearchResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
if v, ok := userSearchFirst(m, "name", "userName", "displayName", "nickName"); ok {
row["name"] = v
}
if v, ok := userSearchFirst(m, "email", "mail", "emailAddress"); ok {
row["email"] = v
}
if v, ok := userSearchFirst(m, "employeeNo", "employee_no", "employeeNumber", "jobNumber"); ok {
row["employeeNo"] = v
}
if v, ok := userSearchFirst(m, "userId", "user_id", "id"); ok {
row["userId"] = v
}
if len(row) > 0 {
out = append(out, row)
}
}
return out
}
// userSearchResolveList locates the list payload inside the response, tolerating
// a bare top-level array container or nesting one level deeper.
func userSearchResolveList(data map[string]any) []any {
for _, key := range []string{"result", "data", "list", "items", "users"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "users", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
}
// userSearchFirst returns the first present candidate key's value.
func userSearchFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
if v, ok := m[k]; ok {
return v, true
}
}
return nil, false
}
// ── attachment ─────────────────────────────────────────────
// AttachmentList 列举邮件附件。
@@ -628,73 +402,20 @@ var TemplateList = shortcut.Shortcut{
if err != nil {
return err
}
templates := templateListProject(data)
return rt.Output(map[string]any{"count": len(templates), "templates": templates})
templates, err := mailProjectCollection(data, "mail/list_user_message_templates", "templates", []string{"id"}, map[string][]string{
"id": {"id"}, "name": {"name"}, "subject": {"subject"},
})
if err != nil {
return err
}
complete, next, err := mailPage(data, "mail/list_user_message_templates", "")
if err != nil {
return err
}
return rt.Output(mailCollectionPayload("templates", templates, complete, next))
},
}
// templateListProject reshapes the raw list_user_message_templates response into
// a clean {id, name, subject} template list — clean output projection.
// Both the list container and per-item field names are probed defensively
// across candidate keys, so an empty/unknown shape yields an empty list rather
// than a crash or fabricated data.
func templateListProject(data map[string]any) []map[string]any {
raw := templateListResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
if v, ok := templateListFirst(m, "id", "templateId", "template_id"); ok {
row["id"] = v
}
if v, ok := templateListFirst(m, "name", "templateName", "template_name", "displayName"); ok {
row["name"] = v
}
if v, ok := templateListFirst(m, "subject", "title"); ok {
row["subject"] = v
}
if len(row) > 0 {
out = append(out, row)
}
}
return out
}
// templateListResolveList locates the list payload inside the response,
// tolerating a bare top-level array container or nesting one level deeper.
func templateListResolveList(data map[string]any) []any {
for _, key := range []string{"result", "data", "list", "items", "templates"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "templates", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
}
// templateListFirst returns the first present candidate key's value.
func templateListFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
if v, ok := m[k]; ok {
return v, true
}
}
return nil, false
}
// TemplateGet 获取邮件模板详情。
// TemplateUpdate 更新邮件模板。
// TemplateDelete 删除邮件模板。
@@ -754,73 +475,20 @@ var ContactList = shortcut.Shortcut{
if err != nil {
return err
}
contacts := contactListProject(data)
return rt.Output(map[string]any{"count": len(contacts), "contacts": contacts})
contacts, err := mailProjectCollection(data, "mail/list_user_mail_contacts", "contacts", []string{"id"}, map[string][]string{
"id": {"id"}, "contactEmail": {"contactEmail", "email"}, "displayName": {"displayName", "name"},
})
if err != nil {
return err
}
complete, next, err := mailPage(data, "mail/list_user_mail_contacts", "")
if err != nil {
return err
}
return rt.Output(mailCollectionPayload("contacts", contacts, complete, next))
},
}
// contactListProject reshapes the raw list_user_mail_contacts response into a
// clean {id, contactEmail, displayName} contact list — output-projection
// clean output projection. Both the list container and per-item field names are probed
// defensively across candidate keys, so an empty/unknown shape yields an empty
// list rather than a crash or fabricated data.
func contactListProject(data map[string]any) []map[string]any {
raw := contactListResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
if v, ok := contactListFirst(m, "id", "contactId", "contact_id"); ok {
row["id"] = v
}
if v, ok := contactListFirst(m, "contactEmail", "contact_email", "email"); ok {
row["contactEmail"] = v
}
if v, ok := contactListFirst(m, "displayName", "display_name", "name"); ok {
row["displayName"] = v
}
if len(row) > 0 {
out = append(out, row)
}
}
return out
}
// contactListResolveList locates the list payload inside the response,
// tolerating a bare top-level array container or nesting one level deeper.
func contactListResolveList(data map[string]any) []any {
for _, key := range []string{"result", "data", "list", "items", "contacts"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "contacts", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
}
// contactListFirst returns the first present candidate key's value.
func contactListFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
if v, ok := m[k]; ok {
return v, true
}
}
return nil, false
}
// ContactUpdate 更新邮件联系人。
// ContactBatchDelete 批量删除邮件联系人。
// ── auto-reply ─────────────────────────────────────────────
@@ -834,6 +502,7 @@ func contactListFirst(m map[string]any, keys ...string) (any, bool) {
// RuleDelete 删除个人收信规则。
// RuleAdjust 调整收信规则排序。
func init() {
hardenPublicMailContracts()
shortcut.Register(
ThreadList,
FolderList,
@@ -0,0 +1,62 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package mail
import (
"context"
"io"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type mailConfirmationCaller struct{ calls int }
func (caller *mailConfirmationCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
caller.calls++
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true}`}}}, nil
}
func (*mailConfirmationCaller) Format() string { return "json" }
func (*mailConfirmationCaller) DryRun() bool { return false }
func (*mailConfirmationCaller) Fields() string { return "" }
func (*mailConfirmationCaller) JQ() string { return "" }
func runUnconfirmedMailWrite(t *testing.T, caller *mailConfirmationCaller, args ...string) error {
t.Helper()
helpers.InitDeps(caller)
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
ctx, _ := output.WithResultStore(context.Background())
root.SetContext(ctx)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.AddCommand(shortcut.Commands()...)
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageMailConfirmationPrecedesRemoteCall(t *testing.T) {
cases := [][]string{
{"mail", "+draft-create", "--from", "sender@example.invalid", "--subject", "fixture"},
{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-id", "--subject", "fixture"},
{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "fixture", "--subject", "fixture", "--body", "fixture"},
{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-id", "--subject", "fixture"},
}
for _, args := range cases {
caller := &mailConfirmationCaller{}
if err := runUnconfirmedMailWrite(t, caller, args...); err == nil {
t.Fatalf("unconfirmed %v unexpectedly succeeded", args)
}
if caller.calls != 0 {
t.Fatalf("unconfirmed %v reached MCP %d times", args, caller.calls)
}
}
}
@@ -0,0 +1,93 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package mail
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
func TestCrossPlatformCoverageMailContractsAreUnifiedAndTyped(t *testing.T) {
declarations := []*struct {
rollout output.RolloutState
result bool
}{
{ThreadList.OutputRollout, ThreadList.Contract.Result != nil},
{FolderList.OutputRollout, FolderList.Contract.Result != nil},
{TagList.OutputRollout, TagList.Contract.Result != nil},
{UserSearch.OutputRollout, UserSearch.Contract.Result != nil},
{TemplateList.OutputRollout, TemplateList.Contract.Result != nil},
{ContactList.OutputRollout, ContactList.Contract.Result != nil},
{Message.OutputRollout, Message.Contract.Result != nil},
{Messages.OutputRollout, Messages.Contract.Result != nil},
{Thread.OutputRollout, Thread.Contract.Result != nil},
{DraftCreate.OutputRollout, DraftCreate.Contract.Result != nil},
{DraftEdit.OutputRollout, DraftEdit.Contract.Result != nil},
{TemplateCreate.OutputRollout, TemplateCreate.Contract.Result != nil},
{TemplateUpdate.OutputRollout, TemplateUpdate.Contract.Result != nil},
}
for index, declaration := range declarations {
if declaration.rollout != output.RolloutUnifiedActive || !declaration.result {
t.Fatalf("mail declaration %d is not unified with Result", index)
}
}
}
func TestCrossPlatformCoverageMailWritesRequireConfirmation(t *testing.T) {
for _, declaration := range []struct {
name string
confirmation string
}{
{DraftCreate.Command, DraftCreate.Safety.Confirmation},
{DraftEdit.Command, DraftEdit.Safety.Confirmation},
{TemplateCreate.Command, TemplateCreate.Safety.Confirmation},
{TemplateUpdate.Command, TemplateUpdate.Safety.Confirmation},
} {
if declaration.confirmation != "user_required" {
t.Errorf("%s confirmation=%q, want user_required", declaration.name, declaration.confirmation)
}
}
}
func TestCrossPlatformCoverageMailStrictResponseMatrix(t *testing.T) {
validEmpty := map[string]any{"success": "true", "folders": []any{}}
items, err := mailRequireCollection(validEmpty, "mail/list_folders", "folders")
if err != nil || len(items) != 0 {
t.Fatalf("explicit empty collection must succeed: items=%v err=%v", items, err)
}
for name, fixture := range map[string]map[string]any{
"missing success": {"folders": []any{}},
"missing collection": {"success": "true"},
"wrong collection": {"success": true, "folders": map[string]any{}},
"bad item": {"success": "true", "folders": []any{"bad"}},
} {
if _, err := mailRequireCollection(fixture, "mail/list_folders", "folders"); err == nil {
t.Fatalf("%s must fail closed", name)
}
}
if err := mailRequireSuccess(map[string]any{"success": "false"}, "mail/test"); err == nil {
t.Fatal("string false must not be success")
}
if err := mailRequireSuccess(map[string]any{"success": true}, "mail/test"); err != nil {
t.Fatalf("boolean true must be accepted: %v", err)
}
}
func TestCrossPlatformCoverageMailPaginationAndIdentity(t *testing.T) {
complete, next, err := mailPage(map[string]any{"nextCursor": "$"}, "mail/search", "")
if err != nil || !complete || next != "" {
t.Fatalf("terminal dollar cursor mismatch: complete=%v next=%q err=%v", complete, next, err)
}
complete, next, err = mailPage(map[string]any{"hasMore": "false", "nextCursor": ""}, "mail/list", "")
if err != nil || !complete || next != "" {
t.Fatalf("string false pagination mismatch: complete=%v next=%q err=%v", complete, next, err)
}
if _, _, err := mailPage(map[string]any{"hasMore": true, "nextCursor": ""}, "mail/list", ""); err == nil {
t.Fatal("hasMore without a progressing cursor must fail")
}
if err := mailRequireIdentity(map[string]any{"id": "actual"}, "mail/get", "expected", "id"); err == nil {
t.Fatal("identity mismatch must fail")
}
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package mail
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func mailResolveMailbox(rt *shortcut.RuntimeContext) (string, error) {
if email := strings.TrimSpace(rt.Str("email")); email != "" {
return email, nil
}
data, err := rt.CallMCPData("mail", "list_user_mailboxes", nil)
if err != nil {
return "", err
}
mailboxes, err := mailRequireCollection(data, "mail/list_user_mailboxes", "emailAccounts")
if err != nil {
return "", err
}
if len(mailboxes) == 0 {
return "", apperrors.NewValidation("当前身份没有可用邮箱,请用 --email 指定")
}
email := mailFirstString(mailboxes[0], "email")
if email == "" {
return "", mailResponseError("mail/list_user_mailboxes", "missing_mailbox_identity", "首个邮箱对象缺少 email")
}
return email, nil
}
func mailReadMessage(rt *shortcut.RuntimeContext, email, id string) (map[string]any, error) {
data, err := rt.CallMCPData("mail", "get_email_by_message_id", map[string]any{"email": email, "messageId": id})
if err != nil {
return nil, err
}
message, err := mailRequireObject(data, "mail/get_email_by_message_id", "message")
if err != nil {
return nil, err
}
if err := mailRequireIdentity(message, "mail/get_email_by_message_id", id, "id"); err != nil {
return nil, err
}
return message, nil
}
var Message = shortcut.Shortcut{
OutputRollout: output.RolloutUnifiedActive,
Service: "mail", Command: "+message", Product: "mail",
Description: "读取一封邮件的完整正文与附件元数据", Intent: "已知单个 messageId,需要读取完整正文和附件元数据时使用;自动解析当前邮箱,并要求返回邮件 ID 与请求精确一致。",
Risk: shortcut.RiskRead, Safety: mailReadSafety(),
Contract: mailReadContract("+message", "读取一封邮件的完整正文与附件元数据", "已知单个 messageId,需要读取完整正文和附件元数据时使用;自动解析当前邮箱,并要求返回邮件 ID 与请求精确一致。", mailObjectResult("身份匹配的单封邮件详情"), []contract.ParamDecl{{Name: "id", Property: "messageId"}}, `dws mail +message --id <messageId> --format json`),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件 messageId"},
{Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"},
},
Execute: func(rt *shortcut.RuntimeContext) error {
email, err := mailResolveMailbox(rt)
if err != nil {
return err
}
message, err := mailReadMessage(rt, email, rt.Str("id"))
if err != nil {
return err
}
return rt.Output(map[string]any{"value": message})
},
}
var Messages = shortcut.Shortcut{
OutputRollout: output.RolloutUnifiedActive,
Service: "mail", Command: "+messages", Product: "mail",
Description: "按请求顺序读取多封邮件并逐封验证身份", Intent: "需要一次读取多个 messageId 时使用;按输入顺序逐封读取,任何缺失、错型或身份不匹配都会使整次任务失败。",
Risk: shortcut.RiskRead, Safety: mailReadSafety(),
Contract: mailReadContract("+messages", "按请求顺序读取多封邮件并逐封验证身份", "需要一次读取多个 messageId 时使用;按输入顺序逐封读取,任何缺失、错型或身份不匹配都会使整次任务失败。", mailCollectionResult("messages", "身份匹配且保持请求顺序的邮件详情"), []contract.ParamDecl{{Name: "ids", Property: "messageIds"}}, `dws mail +messages --ids <id1>,<id2> --format json`),
Flags: []shortcut.Flag{
{Name: "ids", Type: shortcut.FlagStringSlice, Required: true, Desc: "邮件 messageId 列表,最多 100 个"},
{Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"},
},
Execute: func(rt *shortcut.RuntimeContext) error {
ids := rt.StrSlice("ids")
if len(ids) == 0 || len(ids) > 100 {
return apperrors.NewValidation("--ids 需要 1 到 100 个邮件 ID")
}
email, err := mailResolveMailbox(rt)
if err != nil {
return err
}
messages := make([]map[string]any, 0, len(ids))
for index, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
return apperrors.NewValidation(fmt.Sprintf("--ids 第 %d 项为空", index))
}
message, err := mailReadMessage(rt, email, id)
if err != nil {
return err
}
messages = append(messages, message)
}
return rt.Output(mailCollectionPayload("messages", messages, true, ""))
},
}
var Thread = shortcut.Shortcut{
OutputRollout: output.RolloutUnifiedActive,
Service: "mail", Command: "+thread", Product: "mail",
Description: "读取完整邮件会话并精确验证 conversationId", Intent: "已知一个 conversationId,需要查看同一主题的会话上下文时使用;自动解析邮箱并拒绝空对象或错会话。",
Risk: shortcut.RiskRead, Safety: mailReadSafety(),
Contract: mailReadContract("+thread", "读取完整邮件会话并精确验证 conversationId", "已知一个 conversationId,需要查看同一主题的会话上下文时使用;自动解析邮箱并拒绝空对象或错会话。", mailObjectResult("身份匹配的邮件会话详情"), []contract.ParamDecl{{Name: "id", Property: "conversationId"}}, `dws mail +thread --id <conversationId> --format json`),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件会话 conversationId"},
{Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"},
},
Execute: func(rt *shortcut.RuntimeContext) error {
email, err := mailResolveMailbox(rt)
if err != nil {
return err
}
data, err := rt.CallMCPData("mail", "get_thread", map[string]any{"email": email, "conversationId": rt.Str("id")})
if err != nil {
return err
}
conversation, err := mailRequireObject(data, "mail/get_thread", "conversation")
if err != nil {
return err
}
if err := mailRequireIdentity(conversation, "mail/get_thread", rt.Str("id"), "id"); err != nil {
return err
}
return rt.Output(map[string]any{"value": conversation})
},
}
func init() {
shortcut.Register(Message, Messages, Thread)
}
@@ -21,18 +21,23 @@ import (
// TestThreadListProjectConversationsShape guards the exact
// list_mailbox_threads response contract, including lastModifiedDateTime.
func TestThreadListProjectConversationsShape(t *testing.T) {
const raw = `{"result":{"conversations":[{
const raw = `{"success":true,"result":{"conversations":[{
"id":"thread-1",
"subject":"Projection contract",
"lastModifiedDateTime":"2026-07-26T10:00:00Z",
"isRead":false
}]}}`
}],"hasMore":false,"nextCursor":""}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
got := threadListProject(data)
got, err := mailProjectCollection(data, "mail/list_mailbox_threads", "result.conversations", []string{"id"}, map[string][]string{
"conversationId": {"id"}, "subject": {"subject"}, "lastUpdated": {"lastModifiedDateTime"}, "isRead": {"isRead"},
})
if err != nil {
t.Fatalf("strict projection: %v", err)
}
if len(got) != 1 {
t.Fatalf("lower/upper mismatch: result.conversations has 1 entry, projection returned %d (%v)", len(got), got)
}
+287
View File
@@ -0,0 +1,287 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package mail
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func mailRecipients(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
for _, part := range strings.Split(value, ",") {
if trimmed := strings.TrimSpace(part); trimmed != "" {
out = append(out, trimmed)
}
}
}
return out
}
func mailDraftMessage(data map[string]any, operation string) (map[string]any, error) {
if err := mailRequireSuccess(data, operation); err != nil {
return nil, err
}
value, present := mailLookup(data, "result.message")
if !present {
return nil, mailResponseError(operation, "missing_write_receipt", "写响应缺少 result.message")
}
message, ok := value.(map[string]any)
if !ok || len(message) == 0 {
return nil, mailResponseError(operation, "malformed_write_receipt", fmt.Sprintf("result.message 应为非空对象,实际为 %T", value))
}
if err := mailRequireIdentity(message, operation, "", "id"); err != nil {
return nil, err
}
return message, nil
}
func mailReadTemplate(rt *shortcut.RuntimeContext, email, id string) (map[string]any, error) {
data, err := rt.CallMCPData("mail", "get_user_message_template", map[string]any{"email": email, "id": id})
if err != nil {
return nil, err
}
if err := mailRequireSuccess(data, "mail/get_user_message_template"); err != nil {
return nil, err
}
template := make(map[string]any, len(data))
for key, value := range data {
switch key {
case "success", "errorCode", "errorMsg", "arguments":
continue
default:
template[key] = value
}
}
if len(template) == 0 {
return nil, mailResponseError("mail/get_user_message_template", "missing_result", "模板详情为空")
}
if err := mailRequireIdentity(template, "mail/get_user_message_template", id, "id"); err != nil {
return nil, err
}
return template, nil
}
func mailTemplateSubject(template map[string]any) string {
message, _ := template["message"].(map[string]any)
return mailFirstString(message, "subject")
}
func mailTemplateBody(template map[string]any) string {
message, _ := template["message"].(map[string]any)
return mailFirstString(message, "markdownBody", "body")
}
var DraftCreate = shortcut.Shortcut{
OutputRollout: output.RolloutUnifiedActive,
Service: "mail", Command: "+draft-create", Product: "mail",
Description: "创建邮件草稿并按 messageId 读回验证", Intent: "撰写一封新邮件但暂不发送;取得稳定草稿 ID 后读取同一对象,并核对主题和正文。",
Risk: shortcut.RiskWrite, Safety: mailWriteSafety("non_idempotent"),
Contract: mailWriteContract("+draft-create", "创建邮件草稿并按 messageId 读回验证", "撰写一封新邮件但暂不发送;取得稳定草稿 ID 后读取同一对象,并核对主题和正文。", []contract.ParamDecl{{Name: "from", Property: "from"}, {Name: "to", Property: "toRecipients"}, {Name: "cc", Property: "ccRecipients"}, {Name: "body", Property: "body"}}, `dws mail +draft-create --from user@company.com --subject "草稿" --body "正文" --format json`),
Flags: []shortcut.Flag{
{Name: "from", Type: shortcut.FlagString, Required: true, Desc: "发件邮箱"},
{Name: "to", Type: shortcut.FlagStringSlice, Desc: "收件邮箱,可多次指定或逗号分隔"},
{Name: "cc", Type: shortcut.FlagStringSlice, Desc: "抄送邮箱,可多次指定或逗号分隔"},
{Name: "subject", Type: shortcut.FlagString, Required: true, Desc: "草稿主题"},
{Name: "body", Type: shortcut.FlagString, Desc: "草稿正文"},
},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"from": rt.Str("from"), "subject": rt.Str("subject")}
if recipients := mailRecipients(rt.StrSlice("to")); len(recipients) > 0 {
params["toRecipients"] = recipients
}
if recipients := mailRecipients(rt.StrSlice("cc")); len(recipients) > 0 {
params["ccRecipients"] = recipients
}
if rt.Changed("body") {
params["body"] = rt.Str("body")
}
if rt.DryRun() {
return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/create_draft"}})
}
written, err := rt.CallMCPWriteDataStrict("mail", "create_draft", params)
if err != nil {
return err
}
receipt, err := mailDraftMessage(written, "mail/create_draft")
if err != nil {
return err
}
id := mailFirstString(receipt, "id")
verified, err := mailReadMessage(rt, rt.Str("from"), id)
if err != nil {
return err
}
if mailFirstString(verified, "subject") != rt.Str("subject") {
return mailResponseError("mail/create_draft", "verification_mismatch", "草稿读回主题与请求不一致")
}
if rt.Changed("body") && mailFirstString(verified, "markdownBody", "body") != rt.Str("body") {
return mailResponseError("mail/create_draft", "verification_mismatch", "草稿读回正文与请求不一致")
}
return rt.Output(map[string]any{"value": verified})
},
}
var DraftEdit = shortcut.Shortcut{
OutputRollout: output.RolloutUnifiedActive,
Service: "mail", Command: "+draft-edit", Product: "mail",
Description: "更新已有草稿并精确读回验证", Intent: "修改尚未发送的草稿;要求至少提供一个变化字段,并在写后按原 messageId 核对修改内容。",
Risk: shortcut.RiskWrite, Safety: mailWriteSafety("unknown"),
Contract: mailWriteContract("+draft-edit", "更新已有草稿并精确读回验证", "修改尚未发送的草稿;要求至少提供一个变化字段,并在写后按原 messageId 核对修改内容。", []contract.ParamDecl{{Name: "id", Property: "id"}, {Name: "from", Property: "from"}, {Name: "to", Property: "toRecipients"}, {Name: "cc", Property: "ccRecipients"}, {Name: "body", Property: "body"}}, `dws mail +draft-edit --from user@company.com --id <messageId> --subject "新主题" --format json`),
Flags: []shortcut.Flag{
{Name: "from", Type: shortcut.FlagString, Required: true, Desc: "发件邮箱"},
{Name: "id", Type: shortcut.FlagString, Required: true, Desc: "草稿 messageId"},
{Name: "to", Type: shortcut.FlagStringSlice, Desc: "新收件邮箱"},
{Name: "cc", Type: shortcut.FlagStringSlice, Desc: "新抄送邮箱"},
{Name: "subject", Type: shortcut.FlagString, Desc: "新主题"},
{Name: "body", Type: shortcut.FlagString, Desc: "新正文"},
},
Execute: func(rt *shortcut.RuntimeContext) error {
if !rt.Changed("to") && !rt.Changed("cc") && !rt.Changed("subject") && !rt.Changed("body") {
return apperrors.NewValidation("至少指定 --to、--cc、--subject、--body 之一")
}
params := map[string]any{"from": rt.Str("from"), "id": rt.Str("id")}
if rt.Changed("to") {
params["toRecipients"] = mailRecipients(rt.StrSlice("to"))
}
if rt.Changed("cc") {
params["ccRecipients"] = mailRecipients(rt.StrSlice("cc"))
}
if rt.Changed("subject") {
params["subject"] = rt.Str("subject")
}
if rt.Changed("body") {
params["body"] = rt.Str("body")
}
if rt.DryRun() {
return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/update_draft"}})
}
written, err := rt.CallMCPWriteDataStrict("mail", "update_draft", params)
if err != nil {
return err
}
receipt, err := mailDraftMessage(written, "mail/update_draft")
if err != nil {
return err
}
if mailFirstString(receipt, "id") != rt.Str("id") {
return mailResponseError("mail/update_draft", "identity_mismatch", "更新回执的 messageId 与请求不一致")
}
verified, err := mailReadMessage(rt, rt.Str("from"), rt.Str("id"))
if err != nil {
return err
}
if rt.Changed("subject") && mailFirstString(verified, "subject") != rt.Str("subject") {
return mailResponseError("mail/update_draft", "verification_mismatch", "草稿读回主题与请求不一致")
}
if rt.Changed("body") && mailFirstString(verified, "markdownBody", "body") != rt.Str("body") {
return mailResponseError("mail/update_draft", "verification_mismatch", "草稿读回正文与请求不一致")
}
return rt.Output(map[string]any{"value": verified})
},
}
var TemplateCreate = shortcut.Shortcut{
OutputRollout: output.RolloutUnifiedActive,
Service: "mail", Command: "+template-create", Product: "mail",
Description: "创建个人邮件模板并按模板 ID 读回", Intent: "保存可复用邮件主题和正文;创建后按稳定模板 ID 读取并核对名称、主题和正文。",
Risk: shortcut.RiskWrite, Safety: mailWriteSafety("non_idempotent"),
Contract: mailWriteContract("+template-create", "创建个人邮件模板并按模板 ID 读回", "保存可复用邮件主题和正文;创建后按稳定模板 ID 读取并核对名称、主题和正文。", []contract.ParamDecl{{Name: "email", Property: "email"}, {Name: "from", Property: "from"}, {Name: "body", Property: "body"}, {Name: "draft", Property: "isDraft"}}, `dws mail +template-create --email user@company.com --name "模板" --subject "主题" --body "正文" --format json`),
Flags: []shortcut.Flag{
{Name: "email", Type: shortcut.FlagString, Required: true, Desc: "模板所属邮箱"},
{Name: "from", Type: shortcut.FlagString, Desc: "模板发件邮箱"},
{Name: "name", Type: shortcut.FlagString, Required: true, Desc: "模板名称"},
{Name: "subject", Type: shortcut.FlagString, Required: true, Desc: "模板主题"},
{Name: "body", Type: shortcut.FlagString, Required: true, Desc: "模板正文"},
{Name: "draft", Type: shortcut.FlagBool, Desc: "创建可编辑的草稿模板"},
},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"email": rt.Str("email"), "name": rt.Str("name"), "subject": rt.Str("subject"), "body": rt.Str("body"), "isDraft": rt.Bool("draft")}
if rt.Changed("from") {
params["from"] = rt.Str("from")
}
if rt.DryRun() {
return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/create_user_message_template"}})
}
written, err := rt.CallMCPWriteDataStrict("mail", "create_user_message_template", params)
if err != nil {
return err
}
if err := mailRequireSuccess(written, "mail/create_user_message_template"); err != nil {
return err
}
id := mailFirstString(written, "id")
if id == "" {
return mailResponseError("mail/create_user_message_template", "missing_stable_id", "创建模板响应缺少稳定 ID")
}
verified, err := mailReadTemplate(rt, rt.Str("email"), id)
if err != nil {
return err
}
if mailFirstString(verified, "name") != rt.Str("name") || mailTemplateSubject(verified) != rt.Str("subject") || mailTemplateBody(verified) != rt.Str("body") {
return mailResponseError("mail/create_user_message_template", "verification_mismatch", "模板读回内容与请求不一致")
}
return rt.Output(map[string]any{"value": verified})
},
}
var TemplateUpdate = shortcut.Shortcut{
OutputRollout: output.RolloutUnifiedActive,
Service: "mail", Command: "+template-update", Product: "mail",
Description: "更新草稿模板并按原 ID 读回验证", Intent: "修改可编辑的草稿模板;写响应成功后按原模板 ID 读取并核对所有请求变更。",
Risk: shortcut.RiskWrite, Safety: mailWriteSafety("unknown"),
Contract: mailWriteContract("+template-update", "更新草稿模板并按原 ID 读回验证", "修改可编辑的草稿模板;写响应成功后按原模板 ID 读取并核对所有请求变更。", []contract.ParamDecl{{Name: "email", Property: "email"}, {Name: "id", Property: "id"}, {Name: "body", Property: "body"}}, `dws mail +template-update --email user@company.com --id <templateId> --subject "新主题" --format json`),
Flags: []shortcut.Flag{
{Name: "email", Type: shortcut.FlagString, Required: true, Desc: "模板所属邮箱"},
{Name: "id", Type: shortcut.FlagString, Required: true, Desc: "模板 ID;仅草稿模板可更新"},
{Name: "name", Type: shortcut.FlagString, Desc: "新模板名称"},
{Name: "subject", Type: shortcut.FlagString, Desc: "新主题"},
{Name: "body", Type: shortcut.FlagString, Desc: "新正文"},
},
Execute: func(rt *shortcut.RuntimeContext) error {
if !rt.Changed("name") && !rt.Changed("subject") && !rt.Changed("body") {
return apperrors.NewValidation("至少指定 --name、--subject、--body 之一")
}
params := map[string]any{"email": rt.Str("email"), "id": rt.Str("id")}
for _, flag := range []string{"name", "subject", "body"} {
if rt.Changed(flag) {
params[flag] = rt.Str(flag)
}
}
if rt.DryRun() {
return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/update_user_message_template"}})
}
written, err := rt.CallMCPWriteDataStrict("mail", "update_user_message_template", params)
if err != nil {
return err
}
if err := mailRequireSuccess(written, "mail/update_user_message_template"); err != nil {
return err
}
verified, err := mailReadTemplate(rt, rt.Str("email"), rt.Str("id"))
if err != nil {
return err
}
if rt.Changed("name") && mailFirstString(verified, "name") != rt.Str("name") {
return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回名称与请求不一致")
}
if rt.Changed("subject") && mailTemplateSubject(verified) != rt.Str("subject") {
return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回主题与请求不一致")
}
if rt.Changed("body") && mailTemplateBody(verified) != rt.Str("body") {
return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回正文与请求不一致")
}
return rt.Output(map[string]any{"value": verified})
},
}
func init() {
shortcut.Register(DraftCreate, DraftEdit, TemplateCreate, TemplateUpdate)
}
@@ -352,13 +352,21 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"drive\u0000+version-history": {},
"drive\u0000+version-revert": {},
"mail\u0000+contact-list": {},
"mail\u0000+draft-create": {},
"mail\u0000+draft-edit": {},
"mail\u0000+find-mail-user": {},
"mail\u0000+folder-list": {},
"mail\u0000+message": {},
"mail\u0000+messages": {},
"mail\u0000+recent-mail": {},
"mail\u0000+search-mail": {},
"mail\u0000+tag-list": {},
"mail\u0000+template-create": {},
"mail\u0000+template-list": {},
"mail\u0000+template-update": {},
"mail\u0000+thread": {},
"mail\u0000+thread-list": {},
"mail\u0000+triage": {},
"mail\u0000+unread-mail": {},
"mail\u0000+user-search": {},
"minutes\u0000+action-items": {},
+4
View File
@@ -37,6 +37,9 @@ var todoSemanticCatalogJSON []byte
//go:embed semantic_catalog_attendance.json
var attendanceSemanticCatalogJSON []byte
//go:embed semantic_catalog_mail.json
var mailSemanticCatalogJSON []byte
type semanticCatalogFile struct {
Version int `json:"version"`
Service string `json:"service"`
@@ -64,6 +67,7 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
calendarSemanticCatalogJSON,
todoSemanticCatalogJSON,
attendanceSemanticCatalogJSON,
mailSemanticCatalogJSON,
)
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
@@ -0,0 +1,25 @@
{
"version": 1,
"service": "mail",
"default_availability": "unavailable",
"shortcuts": {
"+triage": {"disposition":"primary_smart","semantic_delta":"对齐 Lark triage 任务入口,并超过其显式邮箱要求:可自动解析当前邮箱与收件箱,严格处理邮件搜索的终止游标和零命中哨兵。","risk":"read","availability":"available","public":true,"reviewed":true},
"+search-mail": {"disposition":"semantic_adapter","semantic_delta":"用 KQL 搜索邮件摘要;只接受已观测 messages 数组、稳定 messageId 和完整分页证据,窄化支持 Mail 专属 total=0 占位哨兵。","risk":"read","availability":"available","public":true,"reviewed":true},
"+unread-mail": {"disposition":"primary_smart","semantic_delta":"自动解析邮箱并以 isRead:false 搜索未读邮件;不修改已读状态,严格区分合法零命中与坏消息对象。","risk":"read","availability":"available","public":true,"reviewed":true},
"+recent-mail": {"disposition":"primary_smart","semantic_delta":"自动解析邮箱和收件箱,严格读取 result.conversations、稳定会话 ID 与 hasMore/nextCursor 证据。","risk":"read","availability":"available","public":true,"reviewed":true},
"+message": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 单封完整读入口;自动解析邮箱,要求顶层 message 非空且返回 ID 与请求 messageId 精确一致。","risk":"read","availability":"available","public":true,"reviewed":true},
"+messages": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 多封完整读入口;最多 100 个 ID,保持请求顺序,任何单封缺失、错型或身份不一致都会使整次调用失败。","risk":"read","availability":"available","public":true,"reviewed":true},
"+thread": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 完整会话读取;自动解析邮箱并要求 conversation.id 与请求精确一致。","risk":"read","availability":"available","public":true,"reviewed":true},
"+thread-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.conversations,要求每项稳定 ID,并发布 hasMore/nextCursor 完整性。","risk":"read","availability":"available","public":true,"reviewed":true},
"+folder-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 folders 数组及稳定 folder ID;缺字段、错型和坏元素不再退化为空列表。","risk":"read","availability":"available","public":true,"reviewed":true},
"+tag-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 tags 数组及稳定 tag ID;真实非空标签 fixture 已验证。","risk":"read","availability":"available","public":true,"reviewed":true},
"+user-search": {"disposition":"semantic_adapter","semantic_delta":"搜索企业邮箱用户并保留 hasMore/nextCursor;显式空数组合法,坏用户或缺稳定 ID 失败。","risk":"read","availability":"available","public":true,"reviewed":true},
"+find-mail-user": {"disposition":"primary_smart","semantic_delta":"面向联系人解析的精简用户搜索;修复零命中被误报为调用失败,并严格验证用户数组、身份和分页。","risk":"read","availability":"available","public":true,"reviewed":true},
"+template-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 templates 数组、稳定模板 ID 和 nextCursor;已用临时模板证明非空、删除后证明零命中。","risk":"read","availability":"available","public":true,"reviewed":true},
"+contact-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 contacts 数组、稳定联系人 ID,并兼容已观测的字符串 hasMore;已完成创建到列表到清理闭环。","risk":"read","availability":"available","public":true,"reviewed":true},
"+draft-create": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 新建草稿;确认后创建但不发送,要求稳定 messageId 并按同一 ID 读回核对主题和正文。","risk":"write","availability":"available","public":true,"reviewed":true},
"+draft-edit": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 草稿编辑;至少一个变化字段,严格写回执后按原 messageId 读回核对。","risk":"write","availability":"available","public":true,"reviewed":true},
"+template-create": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 模板创建的核心字段;取得稳定模板 ID 后读回核对名称、主题和正文,附件能力另列下游缺口。","risk":"write","availability":"available","public":true,"reviewed":true},
"+template-update": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 草稿模板更新;严格业务成功后按原模板 ID 读回核对全部请求变更。","risk":"write","availability":"available","public":true,"reviewed":true}
}
}
+9 -37
View File
@@ -19,7 +19,6 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
@@ -107,7 +106,10 @@ var FindMailUser = shortcut.Shortcut{
}
// Step 2 — project matched users.
users := findMailUserUnwrap(data)
users, err := smartMailRows(data, "mail/search_mail_users", "users", "id", "email")
if err != nil {
return err
}
results := make([]map[string]any, 0, len(users))
for _, u := range users {
results = append(results, map[string]any{
@@ -121,45 +123,14 @@ var FindMailUser = shortcut.Shortcut{
})
}
// Step 3 — empty result guard.
if len(results) == 0 {
return apperrors.NewValidation("没搜到邮箱联系人")
complete, next, err := smartMailPage(data, "mail/search_mail_users", "")
if err != nil {
return err
}
return rt.Output(map[string]any{"users": results, "count": len(results)})
return rt.Output(smartMailPayload("users", results, complete, next))
},
}
// findMailUserUnwrap extracts the user list from a search_mail_users response.
// The helper documents the list under "users"; we probe several container keys
// at the top level and one level deep, defensively.
func findMailUserUnwrap(data map[string]any) []map[string]any {
keys := []string{"users", "contacts", "result", "data", "list", "items", "records"}
for _, key := range keys {
if arr, ok := data[key].([]any); ok {
return findMailUserToMaps(arr)
}
if inner, ok := data[key].(map[string]any); ok {
for _, k2 := range keys {
if arr, ok := inner[k2].([]any); ok {
return findMailUserToMaps(arr)
}
}
}
}
return nil
}
func findMailUserToMaps(arr []any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
func findMailUserString(m map[string]any, keys ...string) string {
for _, key := range keys {
if s, ok := m[key].(string); ok && s != "" {
@@ -182,5 +153,6 @@ func findMailUserAny(m map[string]any, keys ...string) any {
}
func init() {
hardenSmartMail(&FindMailUser, "users", "严格校验的邮箱用户搜索结果")
shortcut.Register(FindMailUser)
}
@@ -31,34 +31,6 @@ import (
var errStubTool = errors.New("stub tool error")
// TestSearchMailPickMailbox covers every list_user_mailboxes shape: objects with
// an email field and bare string arrays, each at the top level or one level
// under a result/data wrapper. The nested bare-string case regressed silently
// (searchMailToMaps drops strings), making all smart mail shortcuts wrongly
// demand an explicit --email.
func TestSearchMailPickMailbox(t *testing.T) {
cases := []struct {
name, raw, want string
}{
{"object top-level", `{"emailAccounts":[{"email":"a@x.com"}]}`, "a@x.com"},
{"object under result", `{"result":{"emailAccounts":[{"email":"b@x.com"}]}}`, "b@x.com"},
{"bare string top-level", `{"emailAccounts":["c@x.com"]}`, "c@x.com"},
{"bare string under result", `{"result":{"emailAccounts":["d@x.com"]}}`, "d@x.com"},
{"empty", `{"result":{"emailAccounts":[]}}`, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var data map[string]any
if err := json.Unmarshal([]byte(tc.raw), &data); err != nil {
t.Fatal(err)
}
if got := searchMailPickMailbox(data); got != tc.want {
t.Fatalf("searchMailPickMailbox(%s) = %q, want %q", tc.raw, got, tc.want)
}
})
}
}
// TestResolveSpaceItemsWikiSpaces guards that search_wikiSpaces' result.wikiSpaces
// container is probed — otherwise +resolve-space silently finds no candidates.
func TestResolveSpaceItemsWikiSpaces(t *testing.T) {
@@ -142,7 +114,7 @@ func runShortcutErr(t *testing.T, fake *stubMailboxCaller, argv ...string) error
// "provide --email" validation error rather than silently proceeding.
func TestSmartSearchMailNoMailbox(t *testing.T) {
fake := &stubMailboxCaller{byTool: map[string]string{
"list_user_mailboxes": `{"result":{"emailAccounts":[]}}`,
"list_user_mailboxes": `{"success":"true","emailAccounts":[]}`,
}}
err := runShortcutErr(t, fake, "mail", "+search-mail", "--query", "x", "--format", "json")
if err == nil || !strings.Contains(err.Error(), "未找到可用邮箱") {
@@ -200,8 +172,8 @@ func TestCreatedTodosProjectsCards(t *testing.T) {
// the no-mailbox validation error because emailAccounts was not probed.
func TestSmartSearchMailAutoResolvesMailbox(t *testing.T) {
fake := &stubMailboxCaller{byTool: map[string]string{
"list_user_mailboxes": `{"emailAccounts":[{"email":"me@example.com"}]}`,
"search_emails": `{"result":{"messages":[{"subject":"weekly report","from":"a@x.com"}]}}`,
"list_user_mailboxes": `{"success":"true","emailAccounts":[{"email":"me@example.com"}]}`,
"search_emails": `{"success":"true","messages":[{"id":"message-1","subject":"weekly report","from":"a@x.com"}],"nextCursor":""}`,
}}
out := runShortcut(t, fake, "mail", "+search-mail", "--query", "subject:weekly report", "--format", "json")
if strings.Contains(out, "未找到可用邮箱") || out == "" {
+236
View File
@@ -0,0 +1,236 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package smart
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func smartMailError(operation, reason, message string) error {
return apperrors.NewAPI(message,
apperrors.WithOperation(operation),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithRetryable(false),
apperrors.WithReason(reason),
)
}
func smartMailSuccess(data map[string]any, operation string) error {
if len(data) == 0 {
return smartMailError(operation, "empty_tool_response", "服务返回空响应,无法证明结果确实为空")
}
switch value := data["success"].(type) {
case bool:
if value {
return nil
}
case string:
if value == "true" {
return nil
}
}
if _, present := data["success"]; !present {
return smartMailError(operation, "missing_success", "响应缺少 success 业务状态")
}
return smartMailError(operation, "remote_failure", "服务未明确返回成功状态")
}
func smartMailLookup(data map[string]any, path string) (any, bool) {
var current any = data
for _, segment := range strings.Split(path, ".") {
object, ok := current.(map[string]any)
if !ok {
return nil, false
}
current, ok = object[segment]
if !ok {
return nil, false
}
}
return current, true
}
func smartMailCollection(data map[string]any, operation, path string) ([]map[string]any, error) {
if err := smartMailSuccess(data, operation); err != nil {
return nil, err
}
value, present := smartMailLookup(data, path)
if !present {
return nil, smartMailError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path))
}
raw, ok := value.([]any)
if !ok {
return nil, smartMailError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value))
}
items := make([]map[string]any, 0, len(raw))
for index, item := range raw {
object, ok := item.(map[string]any)
if !ok || len(object) == 0 {
return nil, smartMailError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index))
}
items = append(items, object)
}
return items, nil
}
func smartMailRows(data map[string]any, operation, path string, identityKeys ...string) ([]map[string]any, error) {
items, err := smartMailCollection(data, operation, path)
if err != nil {
return nil, err
}
for index, item := range items {
if searchMailFirstString(item, identityKeys...) == "" {
return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index))
}
}
return items, nil
}
// smartMailSearchRows handles the one reviewed Mail-specific zero-hit wire
// shape observed from search_emails: total="0", terminal nextCursor="$", and
// exactly one placeholder whose only fields are null recipient metadata. It is
// deliberately narrower than a general "drop bad item" rule.
func smartMailSearchRows(data map[string]any, operation string) ([]map[string]any, error) {
items, err := smartMailCollection(data, operation, "messages")
if err != nil {
return nil, err
}
total, totalOK := smartMailInt(data["total"])
next, nextOK := data["nextCursor"].(string)
if totalOK && total == 0 && nextOK && (next == "" || next == "$") {
if len(items) == 0 {
return items, nil
}
if len(items) == 1 && smartMailEmptySearchSentinel(items[0]) {
return []map[string]any{}, nil
}
return nil, smartMailError(operation, "conflicting_empty_result", "total=0 的搜索响应包含真实或未审核的消息对象")
}
if totalOK && total < 0 {
return nil, smartMailError(operation, "invalid_total", "邮件搜索 total 不能为负数")
}
for index, item := range items {
if searchMailFirstString(item, "id") == "" {
return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index))
}
}
return items, nil
}
func smartMailEmptySearchSentinel(item map[string]any) bool {
if len(item) == 0 {
return false
}
allowed := map[string]bool{"toRecipients": true, "ccRecipients": true, "bccRecipients": true, "replyTo": true, "tags": true}
for key, value := range item {
if !allowed[key] || value != nil {
return false
}
}
return true
}
func smartMailInt(value any) (int, bool) {
switch typed := value.(type) {
case int:
return typed, true
case float64:
if typed != float64(int(typed)) {
return 0, false
}
return int(typed), true
case string:
parsed, err := strconv.Atoi(typed)
return parsed, err == nil
default:
return 0, false
}
}
func smartMailPage(data map[string]any, operation, prefix string) (bool, string, error) {
container := data
if prefix != "" {
value, present := smartMailLookup(data, prefix)
if !present {
return false, "", smartMailError(operation, "missing_pagination", "响应缺少分页对象")
}
var ok bool
container, ok = value.(map[string]any)
if !ok {
return false, "", smartMailError(operation, "malformed_pagination", "分页对象类型错误")
}
}
raw, present := container["nextCursor"]
next, ok := raw.(string)
if !present || !ok {
return false, "", smartMailError(operation, "missing_pagination", "响应缺少字符串 nextCursor")
}
if rawMore, present := container["hasMore"]; present {
hasMore, ok := smartMailBool(rawMore)
if !ok {
return false, "", smartMailError(operation, "malformed_pagination", "hasMore 应为布尔值")
}
terminal := strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$"
if hasMore == terminal {
return false, "", smartMailError(operation, "conflicting_pagination", "hasMore 与 nextCursor 互相矛盾")
}
if !hasMore {
return true, "", nil
}
return false, next, nil
}
if strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" {
return true, "", nil
}
return false, next, nil
}
func smartMailBool(value any) (bool, bool) {
switch typed := value.(type) {
case bool:
return typed, true
case string:
if typed == "true" {
return true, true
}
if typed == "false" {
return false, true
}
}
return false, false
}
func smartMailResult(collection, description string) *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件记录","additionalProperties":true}},"complete":{"type":"boolean","description":"分页证据是否证明结果完整"},"nextCursor":{"type":"string","description":"结果未完整时的下一页游标"}},"required":["count",%q,"complete"],"additionalProperties":false}`,
description, collection, description, collection)),
}
}
func hardenSmartMail(declaration *shortcut.Shortcut, collection, description string) {
declaration.OutputRollout = output.RolloutUnifiedActive
declaration.Contract.Result = smartMailResult(collection, description)
declaration.Contract.Interface = &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable,
Reason: "Reviewed Mail Shortcut composite: strict success, collection, stable-ID and pagination validation are owned by the executable CLI.",
}
}
func smartMailPayload(collection string, items []map[string]any, complete bool, next string) map[string]any {
payload := map[string]any{"count": len(items), collection: items, "complete": complete}
if !complete {
payload["nextCursor"] = next
}
return payload
}
@@ -0,0 +1,73 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package smart
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
func TestCrossPlatformCoverageSmartMailContractsAreUnifiedAndTyped(t *testing.T) {
for _, declaration := range []struct {
name string
rollout output.RolloutState
result bool
}{
{"search", SearchMail.OutputRollout, SearchMail.Contract.Result != nil},
{"unread", UnreadMail.OutputRollout, UnreadMail.Contract.Result != nil},
{"find-user", FindMailUser.OutputRollout, FindMailUser.Contract.Result != nil},
{"recent", RecentMail.OutputRollout, RecentMail.Contract.Result != nil},
{"triage", TriageMail.OutputRollout, TriageMail.Contract.Result != nil},
} {
if declaration.rollout != output.RolloutUnifiedActive || !declaration.result {
t.Fatalf("%s is not unified with Result", declaration.name)
}
}
}
func TestCrossPlatformCoverageSmartMailZeroHitSentinelIsNarrow(t *testing.T) {
fixture := map[string]any{
"success": "true", "total": "0", "nextCursor": "$",
"messages": []any{map[string]any{"ccRecipients": nil, "toRecipients": nil}},
}
items, err := smartMailSearchRows(fixture, "mail/search_emails")
if err != nil || len(items) != 0 {
t.Fatalf("reviewed zero-hit sentinel must normalize to empty: items=%v err=%v", items, err)
}
for name, bad := range map[string]map[string]any{
"unexpected field": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"subject": nil}}},
"non-null field": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"ccRecipients": "x"}}},
"multiple rows": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"ccRecipients": nil}, map[string]any{"toRecipients": nil}}},
} {
if _, err := smartMailSearchRows(bad, "mail/search_emails"); err == nil {
t.Fatalf("%s must fail closed", name)
}
}
}
func TestCrossPlatformCoverageSmartMailMalformedCollectionsFail(t *testing.T) {
for name, fixture := range map[string]map[string]any{
"missing success": {"messages": []any{}},
"missing collection": {"success": "true", "total": "0", "nextCursor": "$"},
"wrong type": {"success": "true", "messages": map[string]any{}},
"bad item": {"success": "true", "messages": []any{"bad"}},
} {
if _, err := smartMailCollection(fixture, "mail/search_emails", "messages"); err == nil {
t.Fatalf("%s must fail closed", name)
}
}
}
func TestCrossPlatformCoverageSmartMailNestedSendersFailClosed(t *testing.T) {
if _, err := searchMailFrom(map[string]any{"from": []any{"bad"}}); err == nil {
t.Fatal("malformed message from field must fail")
}
if _, err := recentMailSenders(map[string]any{"senders": []any{"bad"}}); err == nil {
t.Fatal("non-object conversation sender must fail")
}
if _, err := recentMailSenders(map[string]any{"senders": []any{map[string]any{"name": ""}}}); err == nil {
t.Fatal("sender without identity must fail")
}
}
+33 -17
View File
@@ -14,6 +14,8 @@
package smart
import (
"fmt"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
@@ -136,23 +138,29 @@ var RecentMail = shortcut.Shortcut{
}
// Step 4 — project each conversation to a compact record.
threads := searchMailUnwrapList(data, "conversations", "threads", "result", "data", "list", "items", "records")
threads, err := smartMailRows(data, "mail/list_mailbox_threads", "result.conversations", "id")
if err != nil {
return err
}
results := make([]map[string]any, 0, len(threads))
for _, t := range threads {
from, err := recentMailSenders(t)
if err != nil {
return err
}
results = append(results, map[string]any{
"subject": searchMailFirstString(t, "subject", "title", "topic"),
"from": recentMailSenders(t),
"from": from,
"date": searchMailFirstAny(t, "lastModifiedDateTime", "date", "sentTime", "sentDate", "receivedDate", "createTime"),
"threadId": searchMailFirstString(t, "threadId", "id", "conversationId"),
})
}
// Empty result guard.
if len(results) == 0 {
return apperrors.NewValidation("最近没有邮件")
complete, next, err := smartMailPage(data, "mail/list_mailbox_threads", "result")
if err != nil {
return err
}
return rt.Output(map[string]any{"count": len(results), "mails": results})
return rt.Output(smartMailPayload("mails", results, complete, next))
},
}
@@ -166,7 +174,10 @@ func recentMailInboxFolder(rt *shortcut.RuntimeContext, email string) (string, e
if err != nil {
return "", err
}
folders := searchMailUnwrapList(data, "folders", "list", "items", "data", "result", "records")
folders, err := smartMailRows(data, "mail/list_folders", "folders", "id")
if err != nil {
return "", err
}
inboxNames := map[string]bool{
"收件箱": true, "inbox": true,
}
@@ -184,16 +195,14 @@ func recentMailInboxFolder(rt *shortcut.RuntimeContext, email string) (string, e
// recentMailSenders reads a conversation's sender list. The helper documents a
// "senders" array of {email, name}; we tolerate a plain string, a single object,
// and fall back to the shared searchMailFrom logic.
func recentMailSenders(t map[string]any) any {
if arr, ok := t["senders"].([]any); ok && len(arr) > 0 {
func recentMailSenders(t map[string]any) (any, error) {
raw, present := t["senders"]
if arr, ok := raw.([]any); ok && len(arr) > 0 {
names := make([]string, 0, len(arr))
for _, it := range arr {
for index, it := range arr {
m, ok := it.(map[string]any)
if !ok {
if s, ok := it.(string); ok && s != "" {
names = append(names, s)
}
continue
if !ok || len(m) == 0 {
return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", fmt.Sprintf("senders[%d] 不是非空对象", index))
}
name := searchMailFirstString(m, "name", "displayName")
addr := searchMailFirstString(m, "email", "emailAddress", "address")
@@ -204,15 +213,22 @@ func recentMailSenders(t map[string]any) any {
names = append(names, addr)
case name != "":
names = append(names, name)
default:
return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", fmt.Sprintf("senders[%d] 缺少姓名或邮箱", index))
}
}
if len(names) > 0 {
return names
return names, nil
}
} else if present {
if _, ok := raw.([]any); !ok {
return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", "senders 应为数组")
}
}
return searchMailFrom(t)
}
func init() {
hardenSmartMail(&RecentMail, "mails", "严格校验的近期邮件会话摘要")
shortcut.Register(RecentMail)
}
+46 -88
View File
@@ -14,6 +14,8 @@
package smart
import (
"fmt"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
@@ -82,6 +84,7 @@ var SearchMail = shortcut.Shortcut{
{Name: "query", Type: shortcut.FlagString, Desc: "KQL 搜索表达式(如 subject:周报、from:alice、folderId:2)", Required: true},
{Name: "email", Type: shortcut.FlagString, Desc: "要搜索的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false},
{Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20)", Required: false},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false},
},
Tips: []string{
`dws mail +search-mail --query "subject:周报"`,
@@ -109,27 +112,42 @@ var SearchMail = shortcut.Shortcut{
if size == "" {
size = "20"
}
data, err := rt.CallMCPData("mail", "search_emails", map[string]any{
args := map[string]any{
"email": email,
"query": rt.Str("query"),
"size": size,
})
}
if rt.Changed("cursor") {
args["cursor"] = rt.Str("cursor")
}
data, err := rt.CallMCPData("mail", "search_emails", args)
if err != nil {
return err
}
// Step 3 — project each message to a compact record.
messages := searchMailMessages(data)
messages, err := smartMailSearchRows(data, "mail/search_emails")
if err != nil {
return err
}
out := make([]map[string]any, 0, len(messages))
for _, m := range messages {
from, err := searchMailFrom(m)
if err != nil {
return err
}
out = append(out, map[string]any{
"subject": searchMailFirstString(m, "subject", "title", "topic"),
"from": searchMailFrom(m),
"from": from,
"date": searchMailFirstAny(m, "date", "sentDate", "receivedDate", "sentTime", "internalDate", "createTime"),
"messageId": searchMailFirstString(m, "messageId", "id", "mailId", "emailId", "internetMessageId"),
})
}
return rt.Output(map[string]any{"messages": out, "email": email})
complete, next, err := smartMailPage(data, "mail/search_emails", "")
if err != nil {
return err
}
return rt.Output(smartMailPayload("messages", out, complete, next))
},
}
@@ -142,109 +160,48 @@ func searchMailFirstMailbox(rt *shortcut.RuntimeContext) (string, error) {
if err != nil {
return "", err
}
if addr := searchMailPickMailbox(data); addr != "" {
return addr, nil
mailboxes, err := smartMailRows(data, "mail/list_user_mailboxes", "emailAccounts", "email")
if err != nil {
return "", err
}
if len(mailboxes) > 0 {
return searchMailFirstString(mailboxes[0], "email"), nil
}
return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址")
}
// searchMailPickMailbox resolves the first usable mailbox address from a
// list_user_mailboxes response. It tolerates every observed shape: objects with
// an email field (result.emailAccounts[].email) and bare string arrays
// (result.emailAccounts[]), each at the top level or one level under a
// result/data wrapper. searchMailToMaps drops plain strings, so the bare-string
// case is scanned separately — otherwise all three smart mail shortcuts wrongly
// report that no mailbox exists.
func searchMailPickMailbox(data map[string]any) string {
containers := []string{"emailAccounts", "mailboxes", "list", "items", "data", "result", "records"}
for _, m := range searchMailUnwrapList(data, containers...) {
if addr := searchMailFirstString(m, "email", "emailAddress", "mailbox", "address", "account"); addr != "" {
return addr
}
}
scanStrings := func(m map[string]any) string {
for _, key := range containers {
if arr, ok := m[key].([]any); ok {
for _, it := range arr {
if s, ok := it.(string); ok && s != "" {
return s
}
}
}
}
return ""
}
if s := scanStrings(data); s != "" {
return s
}
for _, wrap := range []string{"result", "data"} {
if inner, ok := data[wrap].(map[string]any); ok {
if s := scanStrings(inner); s != "" {
return s
}
}
}
return ""
}
// searchMailMessages extracts the message list from a search_emails response.
// The helper documents the list under "messages".
func searchMailMessages(data map[string]any) []map[string]any {
return searchMailUnwrapList(data, "messages", "emails", "list", "items", "data", "result", "records")
}
// searchMailUnwrapList probes the given container keys at the top level, and one
// level deep, returning the first list found as []map[string]any.
func searchMailUnwrapList(data map[string]any, keys ...string) []map[string]any {
for _, key := range keys {
if arr, ok := data[key].([]any); ok {
return searchMailToMaps(arr)
}
if inner, ok := data[key].(map[string]any); ok {
for _, k2 := range keys {
if arr, ok := inner[k2].([]any); ok {
return searchMailToMaps(arr)
}
}
}
}
return nil
}
func searchMailToMaps(arr []any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// searchMailFrom reads a message's sender, tolerating both a plain string and a
// nested object ({name, email/address}).
func searchMailFrom(m map[string]any) any {
switch v := m["from"].(type) {
func searchMailFrom(m map[string]any) (any, error) {
raw, present := m["from"]
switch v := raw.(type) {
case string:
if v != "" {
return v
return v, nil
}
case map[string]any:
name := searchMailFirstString(v, "name", "displayName")
addr := searchMailFirstString(v, "email", "emailAddress", "address")
switch {
case name != "" && addr != "":
return name + " <" + addr + ">"
return name + " <" + addr + ">", nil
case addr != "":
return addr
return addr, nil
case name != "":
return name
return name, nil
}
return nil, smartMailError("mail/search_emails", "malformed_sender", "from 对象缺少姓名或邮箱")
case nil:
if present {
return nil, smartMailError("mail/search_emails", "malformed_sender", "from 不能为 null")
}
default:
return nil, smartMailError("mail/search_emails", "malformed_sender", fmt.Sprintf("from 字段类型错误:%T", raw))
}
if s := searchMailFirstString(m, "sender", "fromAddress", "fromName", "fromEmail"); s != "" {
return s
return s, nil
}
return ""
return "", nil
}
func searchMailFirstString(m map[string]any, keys ...string) string {
@@ -269,5 +226,6 @@ func searchMailFirstAny(m map[string]any, keys ...string) any {
}
func init() {
hardenSmartMail(&SearchMail, "messages", "严格校验的邮件搜索摘要")
shortcut.Register(SearchMail)
}
+96
View File
@@ -0,0 +1,96 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package smart
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// TriageMail aligns the Lark task-level triage entry while preserving DWS's
// easier mailbox and inbox resolution. It returns summaries only; callers use
// +message or +messages for full bodies.
var TriageMail = shortcut.Shortcut{
Service: "mail", Command: "+triage", Product: "mail",
Description: "列出或筛选邮件摘要,自动解析邮箱与收件箱", Intent: "快速浏览收件箱摘要,或用 KQL 条件筛选邮件;不提供条件时自动定位当前邮箱的收件箱,返回可继续交给 +message/+messages 的稳定 ID。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Description: "列出或筛选邮件摘要,自动解析邮箱与收件箱",
Identity: contract.ToolIdentitySpec{
ProductID: "mail", Name: "shortcut_triage", CanonicalPath: "mail.shortcut_triage",
CLIPath: "mail +triage", PrimaryCLIPath: "mail +triage",
},
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "Resolves the current mailbox and inbox locally, then strictly validates search_emails summaries and pagination."},
Selection: contract.SelectionSpec{
AgentSummary: "列出或筛选邮件摘要,自动解析邮箱与收件箱",
UseWhen: []string{"快速浏览收件箱摘要,或用 KQL 条件筛选邮件;不提供条件时自动定位当前邮箱的收件箱,返回可继续交给 +message/+messages 的稳定 ID。"},
AvoidWhen: []string{"已知单个 messageId 并要读正文时用 mail +message;需要多封完整正文时用 mail +messages"},
Examples: []string{`dws mail +triage --limit 20 --format json`, `dws mail +triage --query "isRead:false" --format json`},
},
Parameters: []contract.ParamDecl{{Name: "limit", Property: "size"}, {Name: "cursor", Property: "cursor"}, {Name: "query", Property: "query"}},
},
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "可选 KQL 条件;不传时列出收件箱"},
{Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"},
{Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页摘要数量"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"},
},
Execute: func(rt *shortcut.RuntimeContext) error {
email := rt.Str("email")
if email == "" {
resolved, err := searchMailFirstMailbox(rt)
if err != nil {
return err
}
email = resolved
}
query := rt.Str("query")
if query == "" {
folderID, err := recentMailInboxFolder(rt, email)
if err != nil {
return err
}
query = "folderId:" + folderID
}
args := map[string]any{"email": email, "query": query, "size": rt.Str("limit")}
if rt.Changed("cursor") {
args["cursor"] = rt.Str("cursor")
}
data, err := rt.CallMCPData("mail", "search_emails", args)
if err != nil {
return err
}
messages, err := smartMailSearchRows(data, "mail/search_emails")
if err != nil {
return err
}
rows := make([]map[string]any, 0, len(messages))
for _, message := range messages {
from, err := searchMailFrom(message)
if err != nil {
return err
}
rows = append(rows, map[string]any{
"messageId": searchMailFirstString(message, "id"),
"subject": searchMailFirstString(message, "subject"),
"from": from,
"date": searchMailFirstAny(message, "receivedDateTime", "date", "sentDateTime"),
})
}
complete, next, err := smartMailPage(data, "mail/search_emails", "")
if err != nil {
return err
}
return rt.Output(smartMailPayload("messages", rows, complete, next))
},
}
func init() {
hardenSmartMail(&TriageMail, "messages", "严格校验的邮件摘要与分页证据")
shortcut.Register(TriageMail)
}
+22 -5
View File
@@ -81,6 +81,7 @@ var UnreadMail = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "email", Type: shortcut.FlagString, Desc: "要查询的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false},
{Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20)", Required: false},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false},
},
Tips: []string{
`dws mail +unread-mail`,
@@ -105,30 +106,46 @@ var UnreadMail = shortcut.Shortcut{
if size == "" {
size = "20"
}
data, err := rt.CallMCPData("mail", "search_emails", map[string]any{
args := map[string]any{
"email": email,
"query": "isRead:false",
"size": size,
})
}
if rt.Changed("cursor") {
args["cursor"] = rt.Str("cursor")
}
data, err := rt.CallMCPData("mail", "search_emails", args)
if err != nil {
return err
}
// Step 3 — project each message to a compact record (shared helpers).
messages := searchMailMessages(data)
messages, err := smartMailSearchRows(data, "mail/search_emails")
if err != nil {
return err
}
out := make([]map[string]any, 0, len(messages))
for _, m := range messages {
from, err := searchMailFrom(m)
if err != nil {
return err
}
out = append(out, map[string]any{
"subject": searchMailFirstString(m, "subject", "title", "topic"),
"from": searchMailFrom(m),
"from": from,
"date": searchMailFirstAny(m, "date", "sentDate", "receivedDate", "sentTime", "internalDate", "createTime"),
"messageId": searchMailFirstString(m, "messageId", "id", "mailId", "emailId", "internetMessageId"),
})
}
return rt.Output(map[string]any{"messages": out, "email": email})
complete, next, err := smartMailPage(data, "mail/search_emails", "")
if err != nil {
return err
}
return rt.Output(smartMailPayload("messages", out, complete, next))
},
}
func init() {
hardenSmartMail(&UnreadMail, "messages", "严格校验的未读邮件摘要")
shortcut.Register(UnreadMail)
}
+1
View File
@@ -28,6 +28,7 @@ SEMANTIC_PATHS = [
ROOT / "internal" / "shortcut" / "semantic_catalog_calendar.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_todo.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_attendance.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_mail.json",
]