// Copyright 2026 Alibaba Group // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package smart import ( "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/shortcut" ) // RecentMail: list recent inbox mail threads (conversations) and project a // compact list in one step. // // Steps: // // 1. resolve the mailbox address — use --email when given, otherwise pick the // current user's first bound mailbox via list_user_mailboxes (reusing // searchMailFirstMailbox from search_mail.go); // // 2. resolve the folder ID — list_mailbox_threads requires a folderId (a folder // ID, not a name). Use --folder when given, otherwise resolve the inbox by // listing the mailbox's top-level folders via list_folders and matching the // inbox displayName (收件箱 / Inbox); // // 3. list the folder's threads via list_mailbox_threads (email / folderId / size // mirror the helpers.mail thread-list call; size defaults to 20, capped at // 100, matching the helper's 1..100 limit); // // 4. in Go, project each conversation to {subject, from, date, threadId} and // print the list via rt.Output so it honours --format/--jq/--fields. // // Read-only: it only lists and projects, never mutating any mail. // // dws mail +recent-mail // dws mail +recent-mail --limit 30 // dws mail +recent-mail --email user@company.com --folder 2 var RecentMail = shortcut.Shortcut{ Service: "mail", Command: "+recent-mail", Product: "mail", Description: "列出收件箱近期邮件会话并投影列表(主题/发件人/时间/threadId)", Intent: "当你想快速看一眼自己邮箱里近期的邮件会话(收件箱线程/conversation),只需要一份精简清单(主题、发件人、最后修改时间、会话 threadId)、" + "而不想翻完整正文或原始字段时使用;" + "内部先确定要看的邮箱地址——你可以用 --email 指定,不指定时自动取你绑定的第一个邮箱——再解析要看的文件夹——" + "你可以用 --folder 指定文件夹 ID,不指定时自动定位收件箱——然后列出该文件夹下的近期会话," + "最后在本地把每条会话投影成 {subject, from, date, threadId} 打印出来,可配合 --format/--jq/--fields。" + "这是纯只读操作,只做列举与本地投影,不会修改、发送或删除任何邮件;若最近没有邮件则提示「最近没有邮件」。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: corecmd.ContractDecl{ Identity: contract.ToolIdentitySpec{ ProductID: "mail", Name: "shortcut_recent_mail", CanonicalPath: "mail.shortcut_recent_mail", CLIPath: "mail +recent-mail", PrimaryCLIPath: "mail +recent-mail", }, Description: "列出收件箱近期邮件会话并投影列表(主题/发件人/时间/threadId)", Parameters: []contract.ParamDecl{ {Name: "limit", Property: "limit"}, {Name: "email", Property: "email"}, {Name: "folder", Property: "folder"}, {Name: "cursor", Property: "cursor"}, }, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", }, Selection: contract.SelectionSpec{ AgentSummary: "列出收件箱近期邮件会话并投影列表(主题/发件人/时间/threadId)", UseWhen: []string{"当你想快速看一眼自己邮箱里近期的邮件会话(收件箱线程/conversation),只需要一份精简清单(主题、发件人、最后修改时间、会话 threadId)、而不想翻完整正文或原始字段时使用;内部先确定要看的邮箱地址——你可以用 --email 指定,不指定时自动取你绑定的第一个邮箱——再解析要看的文件夹——你可以用 --folder 指定文件夹 ID,不指定时自动定位收件箱——然后列出该文件夹下的近期会话,最后在本地把每条会话投影成 {subject, from, date, threadId} 打印出来,可配合 --format/--jq/--fields。这是纯只读操作,只做列举与本地投影,不会修改、发送或删除任何邮件;若最近没有邮件则提示「最近没有邮件」。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{ "dws mail +recent-mail", "dws mail +recent-mail --limit 30", }, }, }, Flags: []shortcut.Flag{ {Name: "limit", Type: shortcut.FlagInt, Desc: "返回会话条数上限(可选,默认 20,最大 100)", Required: false}, {Name: "email", Type: shortcut.FlagString, Desc: "要查看的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, {Name: "folder", Type: shortcut.FlagString, Desc: "文件夹 ID(可选,默认定位收件箱)", Required: false}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, }, Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100 之间"}}, Validate: func(rt *shortcut.RuntimeContext) error { return smartMailValidatePageSize(rt, "limit", false) }, Tips: []string{ `dws mail +recent-mail`, `dws mail +recent-mail --limit 30`, `dws mail +recent-mail --email user@company.com --folder 2`, }, Execute: func(rt *shortcut.RuntimeContext) error { // Step 1 — resolve the mailbox address (reuses search_mail.go). email := rt.Str("email") if email == "" { resolved, err := searchMailFirstMailbox(rt) if err != nil { return err } email = resolved } // Step 2 — resolve the folder ID. list_mailbox_threads requires folderId. folderID := rt.Str("folder") if folderID == "" { resolved, err := recentMailInboxFolder(rt, email) if err != nil { return err } folderID = resolved } // Step 3 — list threads. email/folderId/size mirror the helpers.mail // thread-list call; size is an int in 1..100. limit := 20 if rt.Changed("limit") { limit = rt.Int("limit") } args := map[string]any{ "email": email, "folderId": folderID, "size": limit, } if rt.Changed("cursor") { args["cursor"] = rt.Str("cursor") } data, err := rt.CallMCPData("mail", "list_mailbox_threads", args) if err != nil { return err } // Step 4 — project each conversation to a compact record. 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": from, "date": searchMailFirstAny(t, "lastModifiedDateTime", "date", "sentTime", "sentDate", "receivedDate", "createTime"), "threadId": searchMailFirstString(t, "threadId", "id", "conversationId"), }) } complete, next, err := smartMailPage(data, "mail/list_mailbox_threads", "result", rt.Str("cursor")) if err != nil { return err } return smartMailOutputPage(rt, "mails", results, complete, next) }, } // recentMailInboxFolder lists the mailbox's top-level folders via list_folders // and returns the inbox folder ID, matching the inbox displayName defensively. // The helper documents the list under "folders" with id / displayName fields. func recentMailInboxFolder(rt *shortcut.RuntimeContext, email string) (string, error) { data, err := rt.CallMCPData("mail", "list_folders", map[string]any{ "email": email, }) if err != nil { return "", err } folders, err := smartMailRows(data, "mail/list_folders", "folders", "id") if err != nil { return "", err } inboxNames := map[string]bool{ "收件箱": true, "inbox": true, } for _, f := range folders { name := strings.ToLower(strings.TrimSpace(searchMailFirstString(f, "displayName", "name", "folderName"))) if inboxNames[name] { if id := searchMailFirstString(f, "id", "folderId"); id != "" { return id, nil } } } return "", apperrors.NewValidation("未找到收件箱文件夹,请用 --folder 指定文件夹 ID(可通过 dws mail folder list 获取)") } // 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, error) { raw, present := t["senders"] if arr, ok := raw.([]any); ok && len(arr) > 0 { names := make([]string, 0, len(arr)) for index, it := range arr { m, ok := it.(map[string]any) 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") switch { case name != "" && addr != "": names = append(names, name+" <"+addr+">") case addr != "": 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, 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", "严格校验的近期邮件会话摘要") markSmartMailCompatibilityOnly(&RecentMail) shortcut.Register(RecentMail) }