Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions internal/output/lark_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ const (

// IM resource ownership mismatch.
LarkErrOwnershipMismatch = 231205

// Mail send: account / mailbox-level failures returned by
// POST /open-apis/mail/v1/user_mailboxes/:user_mailbox_id/drafts/:draft_id/send.
// These codes indicate the entire batch will keep failing identically and
// are consumed by shortcuts/mail.isFatalSendErr to abort early.
LarkErrMailboxNotFound = 4013 // mailbox not found or not active
LarkErrMailSendQuotaUser = 6007 // user daily send count exceeded
LarkErrMailSendQuotaUserExt = 6008 // user daily external recipient count exceeded
LarkErrMailSendQuotaTenantExt = 6009 // tenant daily external recipient count exceeded
LarkErrMailQuota = 6010 // user mailbox storage quota exceeded
LarkErrTenantStorageLimit = 6013 // tenant storage limit exceeded
)

// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
Expand Down
255 changes: 255 additions & 0 deletions shortcuts/mail/mail_draft_send.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
// 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."},
},
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 := rt.StrSlice("draft-id")

if len(draftIDs) == 0 {
return output.ErrValidation("--draft-id is required")
}
if len(draftIDs) > MaxBatchSendDrafts {
return output.ErrValidation(
"too many drafts: %d > %d (split into multiple batches)",
len(draftIDs), MaxBatchSendDrafts)
}
for _, id := range draftIDs {
if strings.TrimSpace(id) == "" {
return output.ErrValidation("--draft-id contains empty value")
}
}
Comment on lines +123 to +127
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Trim draft IDs before building API paths.

Line 124 validates TrimSpace(id) but the request path still uses untrimmed id, so whitespace-padded CSV values can slip through and hit incorrect endpoints.

Proposed fix
-	for _, id := range draftIDs {
-		if strings.TrimSpace(id) == "" {
+	normalizedDraftIDs := make([]string, 0, len(draftIDs))
+	for _, rawID := range draftIDs {
+		id := strings.TrimSpace(rawID)
+		if id == "" {
 			return output.ErrValidation("--draft-id contains empty value")
 		}
+		normalizedDraftIDs = append(normalizedDraftIDs, id)
 	}
 
-	out := batchSendOutput{MailboxID: mailboxID, Total: len(draftIDs)}
+	out := batchSendOutput{MailboxID: mailboxID, Total: len(normalizedDraftIDs)}
 	stopOnErr := rt.Bool("stop-on-error")
-	for _, id := range draftIDs {
+	for _, id := range normalizedDraftIDs {

Also applies to: 131-135

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/mail/mail_draft_send.go` around lines 123 - 127, The loop over
draftIDs trims id for validation but later uses the original untrimmed id when
building API paths; update the code so you trim once and use the trimmed value
for both validation and path construction (e.g., assign strings.TrimSpace(id) to
a local trimmedID and use trimmedID in the validation check and when building
the request path). Apply the same change to the other loop/block referenced
(lines 131-135) so all uses of draftIDs use the trimmed value consistently.


out := batchSendOutput{MailboxID: mailboxID, Total: len(draftIDs)}
stopOnErr := rt.Bool("stop-on-error")
for _, id := range draftIDs {
// 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) {
// 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
}
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
if stopOnErr {
break
}
continue
}
if reason := extractAutomationDisabledReason(data); reason != "" {
// 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 output.Errorf(output.ExitAPI, "automation_send_disabled",
"automation send is disabled for this mailbox: %s", reason)
}
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)
}
out.SuccessCount = len(out.Sent)
out.FailureCount = len(out.Failed)

rt.Out(out, nil)

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 := rt.StrSlice("draft-id")
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
}

// 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"}: token,
// scope, and app-installation problems 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":
return true
}
if exitErr.Code == 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
}

// 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)"
}
Loading
Loading