Files
larksuite-cli/shortcuts/mail/mail_thread.go
evandance 5e6a3eb857 feat(mail): return typed error envelopes across the mail domain (#1250)
* feat(mail): return typed error envelopes across the mail domain

Replace every produced error path in shortcuts/mail with typed errs.* envelopes, so consumers get stable category, subtype, param/params, hint, retryable, and log_id metadata for classification and recovery instead of free-form message text.

- Locally constructed mail errors move from output.Err* / output.Errorf / final fmt.Errorf / common legacy helpers to errs.* builders, with structured params on multi-flag validation and failed-precondition states kept non-retryable.

- API-call failures move from runtime.CallAPI / DoAPIJSON legacy boundaries to runtime.CallAPITyped or runtime.ClassifyAPIResponse, and mail-specific enrichers read errs.ProblemOf so typed code, subtype, hint, and log_id metadata are preserved.

- Batch draft-send partial failures now use runtime.OutPartialFailure so successful and failed draft sends stay in stdout while the command exits through a typed multi-status signal.

- Add mail-domain typed helpers, mail API code metadata, and guard wiring to keep shortcuts/mail from reintroducing legacy envelopes or legacy API calls.

- Keep genuine intermediate fmt.Errorf wraps in parser/builder layers annotated with nolint comments; command-facing paths wrap them into typed validation, API, network, or internal errors.

* fix(mail): report aborted draft-send batches as a single failure result

When an account-level failure interrupts a batch send after some drafts
already went out, the command previously produced two machine-readable
failure results: the partial-failure ledger on stdout and a second error
envelope on stderr. Consumers could not tell which one to recover from.

The batch ledger is now the only failure result for that case: it gains
aborted and abort_error fields carrying the typed cause, so callers can
see which drafts were sent, which failed, why the batch stopped, and how
to recover — all from stdout. A --stop-on-error stop keeps these fields
unset because stopping early there is the caller's own choice.
2026-06-04 21:02:20 +08:00

139 lines
5.2 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"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.CallAPITyped("GET", mailboxPath(mailboxID, "threads", threadID), params, nil)
if err != nil {
return mailDecorateProblemMessage(err, "failed to get thread")
}
// 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
},
}