mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:55:53 +08:00
* 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.
350 lines
14 KiB
Go
350 lines
14 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package mail
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
// MaxBatchSendDrafts caps the number of draft IDs accepted in a single
|
|
// +draft-send invocation. The limit is purely client-side: it bounds command-
|
|
// line length comfortably below ARG_MAX and keeps the failure blast radius of
|
|
// a single batch small. It is intentionally local to this shortcut (rather
|
|
// than living in limits.go) because no other shortcut shares the semantics.
|
|
const MaxBatchSendDrafts = 50
|
|
|
|
// sentDraft is the per-draft success entry in the +draft-send aggregated
|
|
// output. message_id and thread_id come from the server response of
|
|
// POST /drafts/:draft_id/send.
|
|
type sentDraft struct {
|
|
DraftID string `json:"draft_id"`
|
|
MessageID string `json:"message_id"`
|
|
ThreadID string `json:"thread_id,omitempty"`
|
|
}
|
|
|
|
// failedDraft is the per-draft failure entry. error is the
|
|
// human-readable err.Error() string (typically including ClassifyLarkError
|
|
// hints); v2 may surface a structured errno field separately once the server-
|
|
// side mapping stabilises (see tech-design "待确认事项").
|
|
type failedDraft struct {
|
|
DraftID string `json:"draft_id"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
// batchSendOutput is the JSON envelope data shape:
|
|
//
|
|
// {
|
|
// "mailbox_id": "me",
|
|
// "total": 3,
|
|
// "success_count": 2,
|
|
// "failure_count": 1,
|
|
// "sent": [{"draft_id":..., "message_id":..., "thread_id":...}, ...],
|
|
// "failed":[{"draft_id":..., "error":...}],
|
|
// "aborted": true,
|
|
// "abort_error": {"type":..., "subtype":..., "code":..., "message":..., "hint":...}
|
|
// }
|
|
//
|
|
// failed is marked omitempty so a fully successful batch returns a clean shape
|
|
// without an empty array.
|
|
//
|
|
// aborted reports an account-level abort: the failure repeats identically for
|
|
// every draft, so the remaining drafts were not attempted and retrying the
|
|
// batch as-is fails the same way. abort_error carries the typed error that
|
|
// triggered the abort (same wire shape as a stderr error envelope's error
|
|
// object) so callers can route recovery from stdout alone. A --stop-on-error
|
|
// stop does NOT set aborted: there the failure is draft-level and the caller
|
|
// chose to stop early.
|
|
type batchSendOutput struct {
|
|
MailboxID string `json:"mailbox_id"`
|
|
Total int `json:"total"`
|
|
SuccessCount int `json:"success_count"`
|
|
FailureCount int `json:"failure_count"`
|
|
Sent []sentDraft `json:"sent"`
|
|
Failed []failedDraft `json:"failed,omitempty"`
|
|
Aborted bool `json:"aborted,omitempty"`
|
|
AbortError interface{} `json:"abort_error,omitempty"`
|
|
}
|
|
|
|
// MailDraftSend is the `+draft-send` shortcut: send N existing drafts
|
|
// sequentially via POST /drafts/:draft_id/send, isolating per-draft failures.
|
|
// Risk is "high-risk-write"; callers must pass --yes. User identity only —
|
|
// drafts are user-owned resources and bot has no coherent semantics here.
|
|
//
|
|
// Output schema is the batchSendOutput type above. Partial failures (any
|
|
// failed[]) emit an ok:false multi-status envelope so that agents can
|
|
// distinguish "all sent" from "some sent" without parsing the success_count
|
|
// field.
|
|
var MailDraftSend = common.Shortcut{
|
|
Service: "mail",
|
|
Command: "+draft-send",
|
|
Description: "Send one or more existing mail drafts sequentially. Calls " +
|
|
"POST /drafts/:draft_id/send for each input ID, isolates per-draft " +
|
|
"failures, and aggregates the results. Use after the drafts have " +
|
|
"already been created (via the Lark client, +draft-create, or the " +
|
|
"drafts.create API).",
|
|
Risk: "high-risk-write",
|
|
Scopes: []string{"mail:user_mailbox.message:send"},
|
|
AuthTypes: []string{"user"},
|
|
HasFormat: true,
|
|
Flags: []common.Flag{
|
|
{Name: "mailbox", Desc: "Mailbox email address that owns the drafts (default: me)."},
|
|
{Name: "draft-id", Type: "string_slice", Required: true,
|
|
Desc: "Draft IDs to send; comma-separated or repeat the flag (max 50)."},
|
|
{Name: "stop-on-error", Type: "bool",
|
|
Desc: "Stop at the first recoverable per-draft failure (default: continue and aggregate). " +
|
|
"Fatal errors (auth, permission, network, mailbox-level quota) always abort immediately " +
|
|
"regardless of this flag."},
|
|
},
|
|
Validate: validateDraftSend,
|
|
DryRun: dryRunDraftSend,
|
|
Execute: executeDraftSend,
|
|
}
|
|
|
|
// executeDraftSend runs the +draft-send command:
|
|
//
|
|
// 1. Resolve mailbox ID (defaults to "me" via resolveComposeMailboxID).
|
|
// 2. Validate the draft-id slice (non-empty, under MaxBatchSendDrafts cap,
|
|
// no empty elements).
|
|
// 3. Loop over each draft ID, calling POST .../drafts/:id/send directly via
|
|
// runtime.CallAPITyped. Per-draft outcomes:
|
|
// - fatal err (isFatalSendErr) → abort immediately (bypasses
|
|
// --stop-on-error): with earlier progress, emit the aborted ledger as the
|
|
// single failure result; with none, return the typed error directly.
|
|
// - recoverable err → append to failed[]; honor --stop-on-error.
|
|
// - success + automation_send_disable signal → abort the same way with a
|
|
// failed-precondition error.
|
|
// - success → append to sent[].
|
|
// 4. Emit batchSendOutput via runtime.Out.
|
|
// 5. If any draft failed, emit ok:false via runtime.OutPartialFailure.
|
|
func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
|
|
mailboxID := resolveComposeMailboxID(rt)
|
|
draftIDs, err := normalizedDraftSendIDs(rt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
out := batchSendOutput{MailboxID: mailboxID, Total: len(draftIDs)}
|
|
stopOnErr := rt.Bool("stop-on-error")
|
|
for i, id := range draftIDs {
|
|
idx := i + 1
|
|
writeDraftSendProgressf(rt, "[%d/%d] sending draft %s",
|
|
idx, len(draftIDs), sanitizeForSingleLine(id))
|
|
// Direct CallAPITyped rather than draftpkg.Send: this shortcut never sends
|
|
// a body, so the helper's send_time-aware envelope would add no value.
|
|
data, err := rt.CallAPITyped("POST",
|
|
mailboxPath(mailboxID, "drafts", id, "send"), nil, nil)
|
|
if err != nil {
|
|
if isFatalSendErr(err) {
|
|
writeDraftSendProgressf(rt, "[%d/%d] aborting after draft %s: %s",
|
|
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
|
|
hadProgress := out.hasProgress()
|
|
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
|
|
// Account- / mailbox-level failures (auth, permission, network,
|
|
// quota) will repeat identically for every remaining draft —
|
|
// abort immediately so the caller sees a single clear error
|
|
// instead of 100 redundant failed[] entries. With earlier
|
|
// progress the aborted ledger is the single failure result;
|
|
// with none, stdout stays empty and the typed error envelope is.
|
|
if hadProgress {
|
|
return emitDraftSendAborted(rt, &out, err)
|
|
}
|
|
return err
|
|
}
|
|
writeDraftSendProgressf(rt, "[%d/%d] failed draft %s: %s",
|
|
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
|
|
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
|
|
if stopOnErr {
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
if reason := extractAutomationDisabledReason(data); reason != "" {
|
|
err := mailFailedPreconditionError(
|
|
"automation send is disabled for this mailbox: %s", reason).
|
|
WithHint("enable automation send for this mailbox, or send the draft from the Lark client")
|
|
writeDraftSendProgressf(rt, "[%d/%d] aborting after draft %s: %s",
|
|
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
|
|
// HTTP success (code: 0) but the backend signaled automation send
|
|
// is disabled — every subsequent send will fail the same way, so
|
|
// abort the batch with a single failure result: the aborted ledger
|
|
// when earlier drafts made progress, the typed error otherwise.
|
|
if out.hasProgress() {
|
|
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
|
|
return emitDraftSendAborted(rt, &out, err)
|
|
}
|
|
return err
|
|
}
|
|
s := sentDraft{DraftID: id}
|
|
if v, ok := data["message_id"].(string); ok {
|
|
s.MessageID = v
|
|
}
|
|
if v, ok := data["thread_id"].(string); ok {
|
|
s.ThreadID = v
|
|
}
|
|
out.Sent = append(out.Sent, s)
|
|
if s.MessageID != "" {
|
|
writeDraftSendProgressf(rt, "[%d/%d] sent draft %s message_id=%s",
|
|
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(s.MessageID))
|
|
} else {
|
|
writeDraftSendProgressf(rt, "[%d/%d] sent draft %s",
|
|
idx, len(draftIDs), sanitizeForSingleLine(id))
|
|
}
|
|
}
|
|
if len(out.Failed) == 0 {
|
|
emitDraftSendOutput(rt, &out)
|
|
return nil
|
|
}
|
|
return emitDraftSendPartialFailure(rt, &out)
|
|
}
|
|
|
|
// dryRunDraftSend builds the --dry-run preview: one POST call per draft ID,
|
|
// in input order, with a header description summarising the batch size.
|
|
func dryRunDraftSend(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
|
mailboxID := resolveComposeMailboxID(rt)
|
|
draftIDs, _ := normalizedDraftSendIDs(rt)
|
|
api := common.NewDryRunAPI().Desc(fmt.Sprintf(
|
|
"Send %d existing drafts sequentially", len(draftIDs)))
|
|
for _, id := range draftIDs {
|
|
api = api.POST(mailboxPath(mailboxID, "drafts", id, "send"))
|
|
}
|
|
return api
|
|
}
|
|
|
|
func validateDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
|
|
_, err := normalizedDraftSendIDs(rt)
|
|
return err
|
|
}
|
|
|
|
func normalizedDraftSendIDs(rt *common.RuntimeContext) ([]string, error) {
|
|
return normalizeDraftSendIDs(rt.StrSlice("draft-id"))
|
|
}
|
|
|
|
func normalizeDraftSendIDs(draftIDs []string) ([]string, error) {
|
|
if len(draftIDs) == 0 {
|
|
return nil, mailValidationParamError("--draft-id", "--draft-id is required")
|
|
}
|
|
|
|
normalized := make([]string, 0, len(draftIDs))
|
|
seen := make(map[string]struct{}, len(draftIDs))
|
|
for _, id := range draftIDs {
|
|
trimmed := strings.TrimSpace(id)
|
|
if trimmed == "" {
|
|
return nil, mailValidationParamError("--draft-id", "--draft-id contains empty value")
|
|
}
|
|
if _, ok := seen[trimmed]; ok {
|
|
return nil, mailValidationParamError("--draft-id", "--draft-id contains duplicate value: %s", trimmed)
|
|
}
|
|
seen[trimmed] = struct{}{}
|
|
normalized = append(normalized, trimmed)
|
|
}
|
|
if len(normalized) > MaxBatchSendDrafts {
|
|
return nil, mailValidationParamError("--draft-id",
|
|
"too many drafts: %d > %d (split into multiple batches)",
|
|
len(normalized), MaxBatchSendDrafts)
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
func (out *batchSendOutput) hasProgress() bool {
|
|
return len(out.Sent) > 0 || len(out.Failed) > 0
|
|
}
|
|
|
|
func emitDraftSendOutput(rt *common.RuntimeContext, out *batchSendOutput) {
|
|
out.SuccessCount = len(out.Sent)
|
|
out.FailureCount = len(out.Failed)
|
|
rt.Out(*out, nil)
|
|
}
|
|
|
|
func emitDraftSendPartialFailure(rt *common.RuntimeContext, out *batchSendOutput) error {
|
|
out.SuccessCount = len(out.Sent)
|
|
out.FailureCount = len(out.Failed)
|
|
return rt.OutPartialFailure(*out, nil)
|
|
}
|
|
|
|
// emitDraftSendAborted emits the batch ledger as the single failure result for
|
|
// an account-level abort: the ledger carries aborted/abort_error and the
|
|
// returned partial-failure signal sets the exit code without a second error
|
|
// envelope on stderr.
|
|
func emitDraftSendAborted(rt *common.RuntimeContext, out *batchSendOutput, cause error) error {
|
|
out.Aborted = true
|
|
if typed, ok := errs.UnwrapTypedError(errs.WrapInternal(cause)); ok {
|
|
out.AbortError = typed
|
|
}
|
|
return emitDraftSendPartialFailure(rt, out)
|
|
}
|
|
|
|
func writeDraftSendProgressf(rt *common.RuntimeContext, format string, args ...interface{}) {
|
|
if rt == nil || rt.Factory == nil || rt.Factory.IOStreams == nil || rt.Factory.IOStreams.ErrOut == nil {
|
|
return
|
|
}
|
|
fmt.Fprintf(rt.Factory.IOStreams.ErrOut, "mail +draft-send: "+format+"\n", args...)
|
|
}
|
|
|
|
// isFatalSendErr reports whether err is an account- or mailbox-level failure
|
|
// that will repeat identically for every subsequent draft. Fatal errors
|
|
// bypass --stop-on-error and immediately abort the batch.
|
|
//
|
|
// Trigger conditions:
|
|
//
|
|
// - err does not expose a typed Problem:
|
|
// unknown shapes are treated as fatal so they cannot accidentally
|
|
// accumulate into failed[] for every remaining draft.
|
|
// - Problem.Category ∈ {authentication, authorization, config, network,
|
|
// internal}: token, scope, app-installation problems, throttling,
|
|
// connectivity, SDK, and invalid-response failures are account-level.
|
|
// - Problem.Subtype ∈ {rate_limit, quota_exceeded}: throttling and quota
|
|
// exhaustion are account-level.
|
|
// - Problem.Code ∈ {1234013, 1236007, 1236008, 1236009, 1236010, 1236013}:
|
|
// mailbox missing / quota exhaustion is account-level. Mailbox-not-found
|
|
// stays code-scoped (1234013) rather than matching subtype not_found, so
|
|
// an unrelated not_found — e.g. a single bad draft ID — remains a
|
|
// per-draft recoverable failure.
|
|
func isFatalSendErr(err error) bool {
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
return true
|
|
}
|
|
switch p.Category {
|
|
case errs.CategoryAuthentication, errs.CategoryAuthorization, errs.CategoryConfig, errs.CategoryNetwork, errs.CategoryInternal:
|
|
return true
|
|
}
|
|
if p.Subtype == errs.SubtypeRateLimit || p.Subtype == errs.SubtypeQuotaExceeded {
|
|
return true
|
|
}
|
|
switch p.Code {
|
|
case 1234013, 1236007, 1236008, 1236009, 1236010, 1236013:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// extractAutomationDisabledReason returns the human-readable reason when the
|
|
// send succeeded at HTTP level (code: 0) but the backend reports that
|
|
// automation send is disabled for this mailbox. An empty return value means
|
|
// automation send is enabled.
|
|
//
|
|
// The data["automation_send_disable"] payload is best-effort: a malformed
|
|
// shape or missing reason still produces a generic non-empty message so the
|
|
// caller can surface the disabled status to the user instead of silently
|
|
// continuing.
|
|
func extractAutomationDisabledReason(data map[string]interface{}) string {
|
|
ad, ok := data["automation_send_disable"]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
m, ok := ad.(map[string]interface{})
|
|
if !ok {
|
|
return "automation send disabled (no reason provided)"
|
|
}
|
|
if reason, ok := m["reason"].(string); ok && strings.TrimSpace(reason) != "" {
|
|
return strings.TrimSpace(reason)
|
|
}
|
|
return "automation send disabled (no reason provided)"
|
|
}
|