Files
larksuite-cli/shortcuts/mail/mail_thread.go
xzcong0820 0e70b056f8 feat(mail): bot+mailbox=me validation and dynamic --as help tests (#895)
* feat(mail): bot+mailbox=me validation and dynamic --as help tests

Add validateBotMailboxNotMe helper to shortcuts/mail/helpers.go and
wire it as a Validate callback into +message, +messages, +thread and
+triage, so bot identity combined with the default --mailbox me is
rejected early with a clear fixup hint instead of a late opaque API
error.

The --as help text was already dynamic via AddShortcutIdentityFlag;
add TC-10/TC-11 tests in internal/cmdutil/identity_flag_test.go to
pin that behaviour, and TC-1 through TC-9 in
shortcuts/mail/mail_shortcut_validation_test.go to cover the new
Validate callbacks.

+watch is excluded: its AuthTypes is ["user"], so bot is never valid.

sprint: S2

* test(cmdutil): add Hidden and DefValue assertions to identity flag tests

* fix(mail): add bot+mailbox=me validation to +template-create and +template-update

* fix(mail): add bot+mailbox=me validation to +template-update

* fix(mail): gofmt mail_template_create.go

* fix(mail): gofmt mail_template_update.go

* fix(mail): skip bot+mailbox=me check for print-patch-template local path
2026-05-19 15:07:43 +08:00

140 lines
5.2 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"fmt"
"sort"
"strconv"
"github.com/larksuite/cli/shortcuts/common"
)
// mailThreadOutput is the +thread JSON output: the thread identifier,
// the number of messages in it, and the messages themselves in
// chronological order.
type mailThreadOutput struct {
ThreadID string `json:"thread_id"`
MessageCount int `json:"message_count"`
Messages []map[string]interface{} `json:"messages"`
}
// sortThreadMessagesByInternalDate filters out messages without a message_id
// and orders the rest ascending by internal_date (parsed via
// parseInternalDateMillis). Used to give +thread output a stable
// chronological order regardless of API return order.
func sortThreadMessagesByInternalDate(outs []map[string]interface{}) []map[string]interface{} {
messages := make([]map[string]interface{}, 0, len(outs))
for _, o := range outs {
if strVal(o["message_id"]) != "" {
messages = append(messages, o)
}
}
sort.Slice(messages, func(i, j int) bool {
di, _ := strconv.ParseInt(strVal(messages[i]["internal_date"]), 10, 64)
dj, _ := strconv.ParseInt(strVal(messages[j]["internal_date"]), 10, 64)
return di < dj
})
return messages
}
// MailThread is the `+thread` shortcut: fetch a full mail conversation by
// thread ID, returning every message in chronological order.
var MailThread = common.Shortcut{
Service: "mail",
Command: "+thread",
Description: "Use when querying a full mail conversation/thread by thread ID. Returns all messages in chronological order, including replies and drafts, with body content and attachments metadata, including inline images.",
Risk: "read",
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
{Name: "thread-id", Desc: "Required. Email thread ID", Required: true},
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
{Name: "include-spam-trash", Type: "bool", Desc: "Also return messages from SPAM and TRASH folders (excluded by default)"},
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBotMailboxNotMe(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(runtime)
threadID := runtime.Str("thread-id")
params := map[string]interface{}{"format": messageGetFormat(runtime.Bool("html"))}
if runtime.Bool("include-spam-trash") {
params["include_spam_trash"] = true
}
return common.NewDryRunAPI().
Desc("Fetch all emails in thread with full body content").
GET(mailboxPath(mailboxID, "threads", threadID)).
Params(params)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Bool("print-output-schema") {
printMessageOutputSchema(runtime)
return nil
}
mailboxID := resolveMailboxID(runtime)
hintIdentityFirst(runtime, mailboxID)
threadID := runtime.Str("thread-id")
html := runtime.Bool("html")
// List thread messages with full body content in one call.
params := map[string]interface{}{"format": messageGetFormat(html)}
if runtime.Bool("include-spam-trash") {
params["include_spam_trash"] = true
}
listData, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "threads", threadID), params, nil)
if err != nil {
return fmt.Errorf("failed to get thread: %w", err)
}
// New API: data.thread.messages[]; fallback to old API: data.items[].message
var items []interface{}
if thread, ok := listData["thread"].(map[string]interface{}); ok {
items, _ = thread["messages"].([]interface{})
}
if len(items) == 0 {
items, _ = listData["items"].([]interface{})
}
if len(items) == 0 {
runtime.Out(mailThreadOutput{ThreadID: threadID, MessageCount: 0, Messages: []map[string]interface{}{}}, nil)
return nil
}
outs := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
envelope, ok := item.(map[string]interface{})
if !ok {
continue
}
// Old API wraps each message inside a "message" sub-object; new API puts fields directly.
msg := envelope
if inner, ok := envelope["message"].(map[string]interface{}); ok {
msg = inner
}
outs = append(outs, buildMessageOutput(msg, html))
}
// Sort by internal_date ascending.
messages := sortThreadMessagesByInternalDate(outs)
runtime.Out(mailThreadOutput{ThreadID: threadID, MessageCount: len(messages), Messages: messages}, nil)
for _, item := range items {
envelope, ok := item.(map[string]interface{})
if !ok {
continue
}
msg := envelope
if inner, ok := envelope["message"].(map[string]interface{}); ok {
msg = inner
}
maybeHintReadReceiptRequest(runtime, mailboxID, strVal(msg["message_id"]), msg)
}
return nil
},
}