mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
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.
331 lines
12 KiB
Go
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)"
|
|
}
|