feat(mail): harden and align shortcut workflows
This commit is contained in:
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` 全部历史。
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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": {},
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user