Files
larksuite-cli/shortcuts/mail/mail_draft_send.go
xukuncx ab94ee9f54 feat(mail): add +draft-send shortcut for batch draft sending (#1017)
Add `lark-cli mail +draft-send` shortcut that takes one or more existing
draft IDs and sends each via POST /drafts/:draft_id/send sequentially.
Per-draft failures are isolated and aggregated into a structured output;
fatal failures (auth, permission, network, mailbox quota) abort the
entire batch immediately while recoverable failures honor --stop-on-error.

Also extend internal/output with six mail-send-specific errno constants
(LarkErrMailboxNotFound=4013, LarkErrMailSendQuota{User,UserExt,TenantExt},
LarkErrMailQuota, LarkErrTenantStorageLimit) consumed by isFatalSendErr.

Risk is "high-risk-write" so the framework's --yes gate applies; the
shortcut declares only the minimal mail:user_mailbox.message:send scope
to avoid asking users for permissions it does not need.
2026-05-27 18:12:41 +08:00

331 lines
12 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"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":...}]
// }
//
// failed is marked omitempty so a fully successful batch returns a clean shape
// without an empty array.
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"`
}
// 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[]) return exit 1 with envelope.error.type="partial_failure" 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.CallAPI. Per-draft outcomes:
// - fatal err (isFatalSendErr) → return immediately (bypasses --stop-on-error).
// - recoverable err → append to failed[]; honor --stop-on-error.
// - success + automation_send_disable signal → return immediately with
// ExitAPI/"automation_send_disabled".
// - success → append to sent[].
// 4. Emit batchSendOutput via runtime.Out.
// 5. If any draft failed, return ExitAPI/"partial_failure" so exit code = 1.
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 CallAPI 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.CallAPI("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()})
if hadProgress {
emitDraftSendOutput(rt, &out)
}
// 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.
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 := output.Errorf(output.ExitAPI, "automation_send_disabled",
"automation send is disabled for this mailbox: %s", reason)
writeDraftSendProgressf(rt, "[%d/%d] aborting after draft %s: %s",
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
if out.hasProgress() {
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
emitDraftSendOutput(rt, &out)
}
// 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 descriptive error.
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))
}
}
emitDraftSendOutput(rt, &out)
if out.FailureCount == 0 {
return nil
}
return output.Errorf(output.ExitAPI, "partial_failure",
"%d of %d drafts failed to send", out.FailureCount, out.Total)
}
// 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, output.ErrValidation("--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, output.ErrValidation("--draft-id contains empty value")
}
if _, ok := seen[trimmed]; ok {
return nil, output.ErrValidation("--draft-id contains duplicate value: %s", trimmed)
}
seen[trimmed] = struct{}{}
normalized = append(normalized, trimmed)
}
if len(normalized) > MaxBatchSendDrafts {
return nil, output.ErrValidation(
"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 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 unwrap to an *output.ExitError, or its Detail is missing:
// unknown shapes are treated as fatal so they cannot accidentally
// accumulate into failed[] for every remaining draft.
// - Detail.Type ∈ {"auth", "app_status", "config", "permission",
// "rate_limit", "network"}: token, scope, app-installation problems,
// throttling, and connectivity are account-level.
// - Code == output.ExitNetwork: connectivity loss is account-level.
// - Detail.Code ∈ {LarkErrMailboxNotFound, LarkErrMailSendQuotaUser,
// LarkErrMailSendQuotaUserExt, LarkErrMailSendQuotaTenantExt,
// LarkErrMailQuota, LarkErrTenantStorageLimit}: mailbox / quota
// exhaustion is account-level.
func isFatalSendErr(err error) bool {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return true
}
switch exitErr.Detail.Type {
case "auth", "app_status", "config":
return true
case "permission", "rate_limit", "network":
return true
}
if exitErr.Code == output.ExitNetwork || wrapsExitCode(err, output.ExitNetwork) {
return true
}
switch exitErr.Detail.Code {
case output.LarkErrMailboxNotFound,
output.LarkErrMailSendQuotaUser,
output.LarkErrMailSendQuotaUserExt,
output.LarkErrMailSendQuotaTenantExt,
output.LarkErrMailQuota,
output.LarkErrTenantStorageLimit:
return true
}
return false
}
func wrapsExitCode(err error, code int) bool {
for unwrapped := errors.Unwrap(err); unwrapped != nil; unwrapped = errors.Unwrap(unwrapped) {
if exitErr, ok := unwrapped.(*output.ExitError); ok && exitErr.Code == code {
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)"
}