Files
yangr-happy 3e8b6c6659 Add mail thread management shortcuts (#2370)
* feat: add mail thread manage shortcuts

* Validate mail thread batch size locally

Reject thread management requests with more than 20 unique thread IDs before making an API call. This mirrors the server-side batch limit and keeps the CLI error in the validation path.

* docs(mail): preserve message shortcut guidance

* feat(mail): expose add-folder thread flag

* docs(mail): tighten thread shortcut guidance

* docs(mail): localize thread shortcut sections

* docs(mail): align thread shortcut headings

* test(mail): cover thread management review gaps

* fix(mail): harden thread manage validation

* fix(mail): skip thread label folder prevalidation

* test(mail): cover thread shortcut registration

* docs(mail): localize thread management skills

* docs(mail): tighten thread skill guidance

* docs(mail): trim thread modify guidance

* docs(mail): remove redundant output note

* fix(mail): align thread batch management output

* docs(mail): simplify thread output wording

* fix(mail): remove unused thread folder scope

* docs(mail): remove thread raw endpoint preface

* fix(mail): support bot thread shortcuts

* docs(mail): keep thread skill updates minimal

* docs(mail): minimize mail template bot note

* docs(mail): align bot identity guidance

* fix(auth): mint bot token from env app secret

* Revert "fix(auth): mint bot token from env app secret"

This reverts commit 648460e00dfce136df1dbe3b7448fa52ad6d31a4.

* fix(mail): tighten thread management guidance

* docs(mail): avoid repeating identity support

* docs(mail): keep read identity guidance focused

* fix(mail): simplify thread receipt label error

* docs(mail): avoid repeating shortcut identity details

* docs(mail): simplify thread shortcut guidance

---------

Co-authored-by: yangr-happy <301323675+yangr-happy@users.noreply.github.com>
2026-09-07 15:18:23 +08:00

377 lines
13 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
type threadModifyInput struct {
ThreadIDs []string
AddLabelIDs []string
RemoveLabelIDs []string
FolderID string
}
const mailThreadManageBatchSize = 20
type threadManageSummary struct {
SuccessThreadIDs []string `json:"success_thread_ids"`
FailedThreadIDs []threadManageFailure `json:"failed_thread_ids"`
}
type threadManageFailure struct {
ThreadID string `json:"thread_id"`
Reason string `json:"reason"`
}
// MailThreadModify is the `+thread-modify` shortcut: apply label changes or a
// folder move to existing mail threads in batches of 20.
var MailThreadModify = common.Shortcut{
Service: "mail",
Command: "+thread-modify",
Description: "Modify existing mail threads by adding/removing label IDs or moving them to a folder. Batches thread IDs in groups of 20 and keeps output compact.",
Risk: "write",
Scopes: []string{"mail:user_mailbox.message:modify"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Default: "me", Desc: "Mailbox email address that owns the threads (default: me)."},
{Name: "thread-ids", Type: "string_array", Required: true, Desc: "Thread IDs to modify; comma-separated or repeat the flag."},
{Name: "add-label-ids", Type: "string_slice", Desc: "Label IDs to add. System labels unread/important/other/flagged are normalized to upper case."},
{Name: "remove-label-ids", Type: "string_slice", Desc: "Label IDs to remove. Cannot overlap with --add-label-ids."},
{Name: "add-folder", Desc: "Folder ID to move threads to."},
{Name: "folder-id", Hidden: true, Desc: "Compatibility alias for --add-folder."},
},
Normalize: normalizeThreadModifyCompatibility,
Validate: validateThreadModify,
DryRun: dryRunThreadModify,
Execute: executeThreadModify,
}
// MailThreadTrash is the `+thread-trash` shortcut: soft-delete existing mail
// threads through the thread batch_trash route. Risk is high-risk-write, so the
// runner requires --yes before Execute.
var MailThreadTrash = common.Shortcut{
Service: "mail",
Command: "+thread-trash",
Description: "Soft-delete existing mail threads. Batches thread IDs in groups of 20 and calls batch_trash sequentially. Requires --yes.",
Risk: "high-risk-write",
Scopes: []string{"mail:user_mailbox.message:modify"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Default: "me", Desc: "Mailbox email address that owns the threads (default: me)."},
{Name: "thread-ids", Type: "string_array", Required: true, Desc: "Thread IDs to soft-delete; comma-separated or repeat the flag."},
},
Validate: validateThreadTrash,
DryRun: dryRunThreadTrash,
Execute: executeThreadTrash,
}
func normalizeThreadModifyCompatibility(ctx context.Context, flags *common.FlagContext) error {
if flags.Changed("add-folder") && flags.Changed("folder-id") {
return mailValidationParamError("--folder-id", "--folder-id is a compatibility alias for --add-folder; pass only one of them")
}
if !flags.Changed("folder-id") {
return nil
}
if _, err := normalizeThreadManageFolderForFlag(flags.Str("folder-id"), "--folder-id"); err != nil {
return err
}
return flags.SetCanonicalFrom("folder-id", "add-folder", flags.Str("folder-id"))
}
func validateThreadModify(ctx context.Context, rt *common.RuntimeContext) error {
if err := validateBotMailboxNotMe(rt); err != nil {
return err
}
_, err := buildThreadModifyInput(rt)
return err
}
func dryRunThreadModify(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(rt)
input, _ := buildThreadModifyInput(rt)
api := common.NewDryRunAPI().
Desc("Modify threads sequentially in batches of 20").
Set("batch_size", mailThreadManageBatchSize).
Set("batches", chunkThreadManageIDs(input.ThreadIDs))
for _, batch := range chunkThreadManageIDs(input.ThreadIDs) {
api = api.POST(mailboxPath(mailboxID, "threads", "batch_modify")).
Body(threadModifyBody(threadModifyInput{
ThreadIDs: batch,
AddLabelIDs: input.AddLabelIDs,
RemoveLabelIDs: input.RemoveLabelIDs,
FolderID: input.FolderID,
}))
}
return api
}
func executeThreadModify(ctx context.Context, rt *common.RuntimeContext) error {
mailboxID := resolveMailboxID(rt)
input, err := buildThreadModifyInput(rt)
if err != nil {
return err
}
summary := threadManageSummary{FailedThreadIDs: []threadManageFailure{}}
for _, batch := range chunkThreadManageIDs(input.ThreadIDs) {
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "threads", "batch_modify"), nil,
threadModifyBody(threadModifyInput{
ThreadIDs: batch,
AddLabelIDs: input.AddLabelIDs,
RemoveLabelIDs: input.RemoveLabelIDs,
FolderID: input.FolderID,
}))
if err != nil {
decorated := mailDecorateProblemMessage(err, "failed to modify threads")
for _, id := range batch {
summary.FailedThreadIDs = append(summary.FailedThreadIDs, threadManageFailure{ThreadID: id, Reason: decorated.Error()})
}
continue
}
summary.SuccessThreadIDs = append(summary.SuccessThreadIDs, batch...)
}
emitThreadManageSummary(rt, summary)
if len(summary.SuccessThreadIDs) == 0 && len(summary.FailedThreadIDs) > 0 {
return mailFailedPreconditionError("all thread modify batches failed")
}
return nil
}
func validateThreadTrash(ctx context.Context, rt *common.RuntimeContext) error {
if err := validateBotMailboxNotMe(rt); err != nil {
return err
}
_, err := normalizeThreadManageIDs(rt.StrArray("thread-ids"))
return err
}
func dryRunThreadTrash(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(rt)
threadIDs, _ := normalizeThreadManageIDs(rt.StrArray("thread-ids"))
api := common.NewDryRunAPI().
Desc("Soft-delete threads sequentially in batches of 20").
Set("batch_size", mailThreadManageBatchSize).
Set("batches", chunkThreadManageIDs(threadIDs))
for _, batch := range chunkThreadManageIDs(threadIDs) {
api = api.POST(mailboxPath(mailboxID, "threads", "batch_trash")).
Body(map[string]interface{}{"thread_ids": batch})
}
return api
}
func executeThreadTrash(ctx context.Context, rt *common.RuntimeContext) error {
mailboxID := resolveMailboxID(rt)
threadIDs, err := normalizeThreadManageIDs(rt.StrArray("thread-ids"))
if err != nil {
return err
}
summary := threadManageSummary{FailedThreadIDs: []threadManageFailure{}}
for _, batch := range chunkThreadManageIDs(threadIDs) {
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "threads", "batch_trash"), nil,
map[string]interface{}{"thread_ids": batch})
if err != nil {
decorated := mailDecorateProblemMessage(err, "failed to trash threads")
for _, id := range batch {
summary.FailedThreadIDs = append(summary.FailedThreadIDs, threadManageFailure{ThreadID: id, Reason: decorated.Error()})
}
continue
}
summary.SuccessThreadIDs = append(summary.SuccessThreadIDs, batch...)
}
emitThreadManageSummary(rt, summary)
if len(summary.SuccessThreadIDs) == 0 && len(summary.FailedThreadIDs) > 0 {
return mailFailedPreconditionError("all thread trash batches failed")
}
return nil
}
func buildThreadModifyInput(rt *common.RuntimeContext) (threadModifyInput, error) {
threadIDs, err := normalizeThreadManageIDs(rt.StrArray("thread-ids"))
if err != nil {
return threadModifyInput{}, err
}
addLabels, err := normalizeThreadManageLabels(rt.StrSlice("add-label-ids"), "--add-label-ids")
if err != nil {
return threadModifyInput{}, err
}
removeLabels, err := normalizeThreadManageLabels(rt.StrSlice("remove-label-ids"), "--remove-label-ids")
if err != nil {
return threadModifyInput{}, err
}
if err := validateLabelIntersection(addLabels, removeLabels); err != nil {
return threadModifyInput{}, err
}
folderID, err := normalizeThreadManageFolder(rt.Str("add-folder"))
if err != nil {
return threadModifyInput{}, err
}
if len(addLabels) == 0 && len(removeLabels) == 0 && folderID == "" {
return threadModifyInput{}, mailValidationParamError("--thread-modify", "provide at least one of --add-label-ids, --remove-label-ids, or --add-folder")
}
return threadModifyInput{
ThreadIDs: threadIDs,
AddLabelIDs: addLabels,
RemoveLabelIDs: removeLabels,
FolderID: folderID,
}, nil
}
func normalizeThreadManageLabels(raw []string, flagName string) ([]string, error) {
labels := make([]string, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
for i, part := range raw {
id := strings.TrimSpace(part)
if id == "" {
return nil, mailValidationParamError(flagName, "%s entry %d is empty; remove extra commas or provide valid label IDs", flagName, i+1)
}
if id != part {
return nil, mailValidationParamError(flagName, "%s entry %d (%q): must not contain leading or trailing whitespace", flagName, i+1, part)
}
upper := strings.ToUpper(id)
if upper == readReceiptRequestLabel {
return nil, mailValidationParamError(flagName, "thread 级别不能管理 `READ_RECEIPT_REQUEST`")
}
normalized := id
switch upper {
case "UNREAD", "IMPORTANT", "OTHER", "FLAGGED":
normalized = upper
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
labels = append(labels, normalized)
}
if len(labels) > 20 {
return nil, mailValidationParamError(flagName, "%s accepts at most 20 label IDs (got %d)", flagName, len(labels))
}
return labels, nil
}
func normalizeThreadManageIDs(raw []string) ([]string, error) {
if len(raw) == 0 {
return nil, mailValidationParamError("--thread-ids", "--thread-ids is required")
}
ids := make([]string, 0, len(raw))
seen := map[string]struct{}{}
for tokenIndex, token := range raw {
for _, r := range token {
if r == '\n' || r == '\r' || r == '\t' {
return nil, mailValidationParamError("--thread-ids", "--thread-ids entry %d (%q): must not contain whitespace or control characters", tokenIndex+1, token)
}
}
for partIndex, part := range strings.Split(token, ",") {
if part == "" {
return nil, mailValidationParamError("--thread-ids", "--thread-ids contains empty value; remove extra commas or provide valid thread IDs")
}
id := strings.TrimSpace(part)
if id == "" {
return nil, mailValidationParamError("--thread-ids", "--thread-ids contains empty value; remove extra commas or provide valid thread IDs")
}
if id != part {
return nil, mailValidationParamError("--thread-ids", "--thread-ids entry %d (%q): must not contain leading or trailing whitespace", partIndex+1, part)
}
if err := validateThreadManageID(id, partIndex); err != nil {
return nil, err
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
}
if len(ids) == 0 {
return nil, mailValidationParamError("--thread-ids", "--thread-ids must include at least one non-empty thread ID")
}
return ids, nil
}
func validateThreadManageID(id string, index int) error {
if strings.Trim(id, "0123456789") == "" {
return mailValidationParamError("--thread-ids", "--thread-ids entry %d (%q): numeric primary IDs are not supported; pass the Open API thread_id from mail output", index+1, id)
}
for _, r := range id {
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
continue
}
switch r {
case '+', '/', '=', '_', '-':
continue
default:
return mailValidationParamError("--thread-ids", "--thread-ids entry %d (%q): contains characters outside the Open API thread_id character set", index+1, id)
}
}
return nil
}
func normalizeThreadManageFolder(raw string) (string, error) {
return normalizeThreadManageFolderForFlag(raw, "--add-folder")
}
func normalizeThreadManageFolderForFlag(raw, flagName string) (string, error) {
if raw == "" {
return "", nil
}
folder := strings.TrimSpace(raw)
if folder == "" {
return "", mailValidationParamError(flagName, "%s must not be empty", flagName)
}
if strings.EqualFold(folder, "TRASH") {
return "", mailValidationParamError(flagName, "TRASH is not supported by +thread-modify; use +thread-trash")
}
if system, ok := messageManageSystemFolders[strings.ToUpper(folder)]; ok {
return system, nil
}
return folder, nil
}
func threadModifyBody(input threadModifyInput) map[string]interface{} {
body := map[string]interface{}{"thread_ids": input.ThreadIDs}
if len(input.AddLabelIDs) > 0 {
body["add_label_ids"] = input.AddLabelIDs
}
if len(input.RemoveLabelIDs) > 0 {
body["remove_label_ids"] = input.RemoveLabelIDs
}
if input.FolderID != "" {
body["add_folder"] = input.FolderID
}
return body
}
func chunkThreadManageIDs(ids []string) [][]string {
if len(ids) == 0 {
return nil
}
chunks := make([][]string, 0, (len(ids)+mailThreadManageBatchSize-1)/mailThreadManageBatchSize)
for start := 0; start < len(ids); start += mailThreadManageBatchSize {
end := start + mailThreadManageBatchSize
if end > len(ids) {
end = len(ids)
}
chunks = append(chunks, ids[start:end])
}
return chunks
}
func emitThreadManageSummary(rt *common.RuntimeContext, summary threadManageSummary) {
rt.OutFormat(summary, &output.Meta{Count: len(summary.SuccessThreadIDs)}, func(w io.Writer) {
fmt.Fprintf(w, "success_thread_ids: %d\n", len(summary.SuccessThreadIDs))
fmt.Fprintf(w, "failed_thread_ids: %d\n", len(summary.FailedThreadIDs))
for _, item := range summary.FailedThreadIDs {
fmt.Fprintf(w, "- %s: %s\n", item.ThreadID, item.Reason)
}
})
}