mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
* refactor: retire legacy error envelopes and enforce typed contract
Consolidate all command error reporting onto the typed errs.* contract, remove
the legacy error surface that predated it, and tighten the lint guards so the
contract holds across the whole repository going forward.
Every failure now reaches stderr as one envelope shape: a category, an
optional subtype, a human- and agent-readable message, and a recovery hint,
with invalid parameters listed under `params`. The legacy ExitError envelope,
its constructors, and the boundary bridge that promoted untyped config and
authorization errors are deleted, leaving a single path from error to wire.
Predicate commands keep their silent-exit behavior through a dedicated signal
that carries only an exit code.
Infrastructure paths that still emitted ad-hoc envelopes — flag parsing,
unknown commands and subcommands, plugin and policy guards, confirmation
prompts, and auth/config failures — now classify into the same taxonomy.
Business, API, auth, and config exit codes are preserved; the one behavioral
change is that Cobra usage failures (missing required flag, unknown command,
bad arguments) now emit the typed validation envelope and exit 2, matching the
explicit flag and subcommand guards, instead of Cobra's plain-text exit 1.
Enforcement is repo-wide rather than per-path:
- The errscontract guards run by default everywhere instead of through a
migration allowlist, so legacy envelopes cannot be reintroduced anywhere.
- errorlint runs across the whole repository: every error wrap must use %w and
every comparison must use errors.Is/errors.As, so interior wraps stay legal
but can no longer break the chain the typed boundary relies on.
- The errs-no-bare-wrap guard is keyed by structural prefix instead of an
explicit per-domain allowlist, so new shortcut domains are covered without
editing a list. It runs where forbidigo is enabled (the shortcut domains and
the auth/config/service command groups); repo-wide chain integrity for the
remaining command paths is carried by errorlint above.
* test: align cli_e2e success assertions to the ok envelope
The api and service success path now emits the {"ok":true} envelope, so the
cli_e2e workflow assertions that still expected the old {"code":0} shape via
AssertStdoutStatus(t, 0) fail once they run with live credentials. Switch those
workflow assertions to AssertStdoutStatus(t, true); the fake-payload helper test
in core_test.go keeps its code-shape assertion.
349 lines
14 KiB
Go
349 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
|
|
// message from the underlying error; v2 may surface a structured errno field
|
|
// separately once the server-side mapping stabilises.
|
|
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)"
|
|
}
|