Files
dingtalk-workspace-cli/internal/shortcut/mail/common.go

528 lines
20 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package mail
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"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."
const mailCompatibilityInterfaceReason = "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data or cleanup proof gap is closed."
func mailCollectionResult(collection, description string) *contract.ResultSpec {
identity := mailCollectionIdentitySchema(collection)
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
SensitivePaths: mailCollectionSensitivePaths(collection),
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件业务记录",%s,"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`,
description, collection, description, identity, collection,
)),
}
}
func mailCollectionSensitivePaths(collection string) []string {
switch collection {
case "threads":
return []string{"threads.subject", "threads.lastUpdated"}
case "folders":
return []string{"folders.name"}
case "tags":
return []string{"tags.name"}
case "users":
return []string{"users.name", "users.email", "users.employeeNo"}
case "templates":
return []string{"templates.name", "templates.subject"}
case "contacts":
return []string{"contacts.contactEmail", "contacts.displayName"}
case "messages":
return []string{"messages.subject", "messages.body", "messages.markdownBody", "messages.from", "messages.toRecipients", "messages.ccRecipients", "messages.bccRecipients", "messages.attachments", "messages.receivedDateTime", "messages.sentDateTime"}
default:
panic("unsupported Mail collection sensitive paths: " + collection)
}
}
func mailCollectionIdentitySchema(collection string) string {
switch collection {
case "threads":
return `"properties":{"conversationId":{"type":"string","minLength":1,"description":"稳定邮件会话 ID"}},"required":["conversationId"]`
case "folders", "tags", "templates", "contacts", "messages":
return `"properties":{"id":{"type":"string","minLength":1,"description":"稳定邮件业务对象 ID"}},"required":["id"]`
case "users":
return `"properties":{"userId":{"type":["string","number"],"description":"稳定邮箱用户 ID"},"email":{"type":"string","minLength":1,"description":"稳定邮箱地址身份"}},"anyOf":[{"required":["userId"]},{"required":["email"]}]`
default:
panic("unsupported Mail collection Result identity: " + collection)
}
}
func mailCursorPagination() *contract.PaginationSpec {
return &contract.PaginationSpec{
Kind: contract.PaginationKindCursor,
CursorParameter: "cursor",
MetaPath: contract.PaginationMetaPath,
EndpointExhaustedPath: contract.PaginationExhaustedPath,
NextTokenPath: contract.PaginationNextTokenPath,
}
}
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":"严格校验且身份匹配的邮件业务对象","properties":{"id":{"type":"string","minLength":1,"description":"稳定邮件业务对象 ID"}},"required":["id"],"additionalProperties":true}},"required":["value"],"additionalProperties":false}`,
description,
)),
SensitivePaths: []string{"value.body", "value.markdownBody", "value.subject", "value.summary", "value.name", "value.email", "value.contactEmail", "value.displayName", "value.from", "value.toRecipients", "value.ccRecipients", "value.bccRecipients", "value.senders", "value.attachments", "value.receivedDateTime", "value.sentDateTime"},
}
}
func mailReadSafety() contract.SafetySpec {
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
}
func mailValidatePageSize(rt *shortcut.RuntimeContext, flag string, required bool) error {
if !required && !rt.Changed(flag) {
return nil
}
value := rt.Int(flag)
if value < 1 || value > 100 {
return apperrors.NewValidation(fmt.Sprintf("--%s 必须在 1-100 之间", flag))
}
return nil
}
func mailStringPageSize(rt *shortcut.RuntimeContext, flag string, required bool) (string, error) {
if !required && !rt.Changed(flag) {
return "", nil
}
value, err := strconv.Atoi(rt.Str(flag))
if err != nil || value < 1 || value > 100 {
return "", apperrors.NewValidation(fmt.Sprintf("--%s 必须是 1-100 之间的整数", flag))
}
return strconv.Itoa(value), nil
}
func mailValidateStringPageSize(rt *shortcut.RuntimeContext, flag string, required bool) error {
_, err := mailStringPageSize(rt, flag, required)
return err
}
func mailValidateRequiredText(rt *shortcut.RuntimeContext, flag string) error {
if strings.TrimSpace(rt.Str(flag)) == "" {
return apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", flag))
}
return nil
}
func mailValidateThreadList(rt *shortcut.RuntimeContext) error {
if err := mailValidatePageSize(rt, "limit", true); err != nil {
return err
}
parsed := make(map[string]time.Time, 2)
for _, flag := range []string{"start", "end"} {
if !rt.Changed(flag) {
continue
}
value, err := time.Parse(time.RFC3339, strings.TrimSpace(rt.Str(flag)))
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("--%s 必须是 RFC3339 时间", flag))
}
_, offset := value.Zone()
if offset != 0 {
return apperrors.NewValidation(fmt.Sprintf("--%s 必须使用 UTC 时区", flag))
}
parsed[flag] = value
}
start, hasStart := parsed["start"]
end, hasEnd := parsed["end"]
if hasStart && hasEnd && end.Before(start) {
return apperrors.NewValidation("--end 不能早于 --start")
}
return nil
}
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 {
allowNumericID := false
for _, key := range identityKeys {
if key == "email" {
allowNumericID = true
break
}
}
for index, item := range items {
valid := mailFirstString(item, identityKeys...) != ""
if !valid && allowNumericID {
valid = mailNumericIdentity(item["id"])
}
if !valid {
return mailResponseError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index))
}
}
return nil
}
func mailNumericIdentity(value any) bool {
switch value.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, json.Number:
return true
default:
return false
}
}
func mailProjectValue(operation, outputName string, value any) (any, bool, error) {
if value == nil {
return nil, false, nil
}
switch outputName {
case "email":
text, ok := value.(string)
if !ok {
return nil, false, mailResponseError(operation, "malformed_item_identity", "邮箱用户 email 必须是字符串")
}
text = strings.TrimSpace(text)
return text, text != "", nil
case "userId":
if text, ok := value.(string); ok {
text = strings.TrimSpace(text)
return text, text != "", nil
}
if !mailNumericIdentity(value) {
return nil, false, mailResponseError(operation, "malformed_item_identity", "邮箱用户 userId 必须是字符串或数字")
}
return value, true, nil
default:
if text, ok := value.(string); ok {
if strings.TrimSpace(text) == "" {
return nil, false, nil
}
return text, true, nil
}
return value, true, 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 {
projectedValue, include, err := mailProjectValue(operation, outputName, value)
if err != nil {
return nil, err
}
if !include {
continue
}
row[outputName] = projectedValue
break
}
}
}
projected = append(projected, row)
}
return projected, nil
}
func mailPage(data map[string]any, operation, prefix, currentCursor 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
}
if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) {
return false, "", mailResponseError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor")
}
return false, next, nil
}
if strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" {
return true, "", nil
}
if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) {
return false, "", mailResponseError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor")
}
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 mailBusinessCollectionPayload(collection string, items []map[string]any) map[string]any {
return map[string]any{"count": len(items), collection: items}
}
// mailOutputPage preserves legacy payload compatibility while keeping cursor
// controls in the unified envelope's meta.pagination contract.
func mailOutputPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, next string) error {
payload := mailCollectionPayload(collection, items, complete, next)
if !output.UsesUnifiedResult(rt.Command()) {
return rt.Output(payload)
}
pagination, err := output.NewPagination(complete, next)
if err != nil {
return mailResponseError("mail/pagination", "invalid_pagination", err.Error())
}
business := map[string]any{"count": len(items), collection: items}
meta := &output.Meta{Pagination: pagination, Count: output.NewCount(len(items))}
return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta)))
}
func hardenPublicMailContracts() {
collections := []struct {
declaration *shortcut.Shortcut
collection string
description string
paginated bool
}{
{&ThreadList, "threads", "严格校验的邮件会话列表", true},
{&FolderList, "folders", "严格校验的邮箱文件夹列表", false},
{&TagList, "tags", "严格校验的邮件标签列表", false},
{&UserSearch, "users", "严格校验的企业邮箱用户搜索结果", true},
{&TemplateList, "templates", "严格校验的邮件模板列表", true},
{&ContactList, "contacts", "严格校验的邮件联系人列表", true},
}
for _, item := range collections {
item.declaration.OutputRollout = output.RolloutUnifiedActive
item.declaration.Contract.Result = mailCollectionResult(item.collection, item.description)
if item.paginated {
item.declaration.Contract.Pagination = mailCursorPagination()
}
item.declaration.Contract.Interface = &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: mailCompositeReason}
}
markMailCompatibilityOnly(&ThreadList)
markMailCompatibilityOnly(&TagList)
markMailCompatibilityOnly(&TemplateList)
markMailCompatibilityOnly(&ContactList)
}
func markMailCompatibilityOnly(declaration *shortcut.Shortcut) {
declaration.OutputRollout = output.RolloutLegacyOnly
declaration.Contract.Result = nil
declaration.Contract.Pagination = nil
declaration.Contract.Interface = &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite,
Availability: contract.InterfaceAvailable,
Reason: mailCompatibilityInterfaceReason,
}
}
func markMailUnavailable(declaration *shortcut.Shortcut, reason string) {
declaration.OutputRollout = output.RolloutLegacyOnly
declaration.Contract.Result = nil
declaration.Contract.Pagination = nil
declaration.Contract.Interface = &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite,
Availability: contract.InterfaceUnavailable,
Reason: reason,
}
}