Files
dingtalk-workspace-cli/internal/shortcut/smart/search_mail.go
T

274 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (
"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"
)
// SearchMail: search a mailbox by keyword 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;
//
// 2. search that mailbox via search_emails (email / query / size mirror
// helpers.messageSearch; size defaults to "20" as a string, matching the
// helper's sizeVal handling);
//
// 3. in Go, project each returned message to {subject, from, date, messageId}
// 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 +search-mail --query "subject:周报"
// dws mail +search-mail --query "from:alice AND date>2025-06-01T00:00:00Z"
// dws mail +search-mail --email user@company.com --query "hasAttachments:true"
var SearchMail = shortcut.Shortcut{
Service: "mail",
Command: "+search-mail",
Product: "mail",
Description: "按 KQL 关键词搜索邮件并投影列表(主题/发件人/时间/messageId)",
Intent: "当你想按关键词(KQL 表达式,如 subject:周报、from:alice、hasAttachments:true、folderId:2 等)快速搜自己的邮件、" +
"并只看一份精简清单(主题、发件人、时间、邮件 messageId)而不想翻完整正文时使用;" +
"内部先确定要搜的邮箱地址——你可以用 --email 指定,不指定时自动取你绑定的第一个邮箱——再执行邮件搜索," +
"最后在本地把每封邮件投影成 {subject, from, date, messageId} 打印出来,可配合 --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_search_mail",
CanonicalPath: "mail.shortcut_search_mail",
CLIPath: "mail +search-mail",
PrimaryCLIPath: "mail +search-mail",
},
Description: "按 KQL 关键词搜索邮件并投影列表(主题/发件人/时间/messageId)",
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: "按 KQL 关键词搜索邮件并投影列表(主题/发件人/时间/messageId)",
UseWhen: []string{"当你想按关键词(KQL 表达式,如 subject:周报、from:alice、hasAttachments:true、folderId:2 等)快速搜自己的邮件、并只看一份精简清单(主题、发件人、时间、邮件 messageId)而不想翻完整正文时使用;内部先确定要搜的邮箱地址——你可以用 --email 指定,不指定时自动取你绑定的第一个邮箱——再执行邮件搜索,最后在本地把每封邮件投影成 {subject, from, date, messageId} 打印出来,可配合 --format/--jq/--fields。这是纯只读操作,只做搜索与本地投影,不会修改、发送或删除任何邮件;若没有命中则返回空列表。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{
"dws mail +search-mail --query \"subject:周报\"",
"dws mail +search-mail --query \"from:alice AND date>2025-06-01T00:00:00Z\"",
},
},
},
Flags: []shortcut.Flag{
{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},
},
Tips: []string{
`dws mail +search-mail --query "subject:周报"`,
`dws mail +search-mail --query "from:alice AND date>2025-06-01T00:00:00Z"`,
`dws mail +search-mail --email user@company.com --query "hasAttachments:true"`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
if err := rt.RequireAll("query"); err != nil {
return err
}
// Step 1 — resolve the mailbox address.
email := rt.Str("email")
if email == "" {
resolved, err := searchMailFirstMailbox(rt)
if err != nil {
return err
}
email = resolved
}
// Step 2 — search that mailbox. email/query/size mirror the search_emails
// call in helpers.messageSearch; size is passed as a string.
size := rt.Str("size")
if size == "" {
size = "20"
}
data, err := rt.CallMCPData("mail", "search_emails", map[string]any{
"email": email,
"query": rt.Str("query"),
"size": size,
})
if err != nil {
return err
}
// Step 3 — project each message to a compact record.
messages := searchMailMessages(data)
out := make([]map[string]any, 0, len(messages))
for _, m := range messages {
out = append(out, map[string]any{
"subject": searchMailFirstString(m, "subject", "title", "topic"),
"from": searchMailFrom(m),
"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})
},
}
// searchMailFirstMailbox lists the current user's bound mailboxes via
// list_user_mailboxes and returns the first mailbox address. The gateway wraps
// the list under "mailboxes" (per helper docs); we probe a few container keys
// and address field names defensively.
func searchMailFirstMailbox(rt *shortcut.RuntimeContext) (string, error) {
data, err := rt.CallMCPData("mail", "list_user_mailboxes", nil)
if err != nil {
return "", err
}
if addr := searchMailPickMailbox(data); addr != "" {
return addr, 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) {
case string:
if v != "" {
return v
}
case map[string]any:
name := searchMailFirstString(v, "name", "displayName")
addr := searchMailFirstString(v, "email", "emailAddress", "address")
switch {
case name != "" && addr != "":
return name + " <" + addr + ">"
case addr != "":
return addr
case name != "":
return name
}
}
if s := searchMailFirstString(m, "sender", "fromAddress", "fromName", "fromEmail"); s != "" {
return s
}
return ""
}
func searchMailFirstString(m map[string]any, keys ...string) string {
for _, key := range keys {
if s, ok := m[key].(string); ok && s != "" {
return s
}
}
return ""
}
func searchMailFirstAny(m map[string]any, keys ...string) any {
for _, key := range keys {
if v, ok := m[key]; ok && v != nil {
if s, isStr := v.(string); isStr && s == "" {
continue
}
return v
}
}
return nil
}
func init() {
shortcut.Register(SearchMail)
}