diff --git a/internal/output/lark_errors.go b/internal/output/lark_errors.go index a2ac2590e..cbc0c2981 100644 --- a/internal/output/lark_errors.go +++ b/internal/output/lark_errors.go @@ -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). diff --git a/shortcuts/mail/mail_draft_send.go b/shortcuts/mail/mail_draft_send.go new file mode 100644 index 000000000..daf4042ef --- /dev/null +++ b/shortcuts/mail/mail_draft_send.go @@ -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") + } + } + + 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)" +} diff --git a/shortcuts/mail/mail_draft_send_test.go b/shortcuts/mail/mail_draft_send_test.go new file mode 100644 index 000000000..0d2ba2140 --- /dev/null +++ b/shortcuts/mail/mail_draft_send_test.go @@ -0,0 +1,693 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// TestMailDraftSend_Metadata pins the public surface of the +draft-send +// shortcut: command name, risk level, scopes, auth type, and the three +// declared flags. Changing any of these is a public-contract change and must +// be intentional. +func TestMailDraftSend_Metadata(t *testing.T) { + if MailDraftSend.Service != "mail" { + t.Errorf("Service = %q, want %q", MailDraftSend.Service, "mail") + } + if MailDraftSend.Command != "+draft-send" { + t.Errorf("Command = %q, want %q", MailDraftSend.Command, "+draft-send") + } + if MailDraftSend.Risk != "high-risk-write" { + t.Errorf("Risk = %q, want %q", MailDraftSend.Risk, "high-risk-write") + } + if !MailDraftSend.HasFormat { + t.Error("HasFormat must be true so --format is auto-injected") + } + if len(MailDraftSend.AuthTypes) != 1 || MailDraftSend.AuthTypes[0] != "user" { + t.Errorf("AuthTypes = %v, want [user]", MailDraftSend.AuthTypes) + } + // Minimum-permission rule: only :send. Adding :modify or :readonly here is + // an explicit scope-policy regression. + if len(MailDraftSend.Scopes) != 1 || MailDraftSend.Scopes[0] != "mail:user_mailbox.message:send" { + t.Errorf("Scopes = %v, want [mail:user_mailbox.message:send]", MailDraftSend.Scopes) + } + + flagByName := map[string]common.Flag{} + for _, fl := range MailDraftSend.Flags { + flagByName[fl.Name] = fl + } + mailbox, ok := flagByName["mailbox"] + if !ok { + t.Fatal("missing --mailbox flag") + } + if mailbox.Required { + t.Error("--mailbox must NOT be Required (defaults to me via resolveComposeMailboxID)") + } + if mailbox.Default != "" { + t.Errorf("--mailbox Default should be empty (let resolveComposeMailboxID supply 'me'); got %q", mailbox.Default) + } + draftID, ok := flagByName["draft-id"] + if !ok { + t.Fatal("missing --draft-id flag") + } + if !draftID.Required { + t.Error("--draft-id must be Required so cobra rejects missing-flag invocations") + } + if draftID.Type != "string_slice" { + t.Errorf("--draft-id Type = %q, want %q", draftID.Type, "string_slice") + } + stopOnErr, ok := flagByName["stop-on-error"] + if !ok { + t.Fatal("missing --stop-on-error flag") + } + if stopOnErr.Required { + t.Error("--stop-on-error must be optional") + } + if stopOnErr.Type != "bool" { + t.Errorf("--stop-on-error Type = %q, want %q", stopOnErr.Type, "bool") + } +} + +// stubDraftSend registers a stub for POST .../drafts//send with the +// supplied response body. Used to assemble multi-draft test scenarios. +func stubDraftSend(reg *httpmock.Registry, draftID string, body map[string]interface{}) *httpmock.Stub { + stub := &httpmock.Stub{ + Method: "POST", + URL: "/user_mailboxes/me/drafts/" + draftID + "/send", + Body: body, + } + reg.Register(stub) + return stub +} + +// stubDraftSendStatus registers a stub for POST .../drafts//send +// that returns an HTTP status (used to drive ClassifyLarkError into +// permission / not_found / etc. paths). +func stubDraftSendStatus(reg *httpmock.Registry, draftID string, status int, body map[string]interface{}) *httpmock.Stub { + stub := &httpmock.Stub{ + Method: "POST", + URL: "/user_mailboxes/me/drafts/" + draftID + "/send", + Status: status, + Body: body, + } + reg.Register(stub) + return stub +} + +// TestMailDraftSend_AllSuccess verifies the happy path: every draft sends +// successfully, sent[] is fully populated, failed[] is omitted from the JSON, +// and exit code = 0 (err == nil). +func TestMailDraftSend_AllSuccess(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + stubDraftSend(reg, "d1", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "message_id": "msg_1", + "thread_id": "thread_1", + }, + }) + stubDraftSend(reg, "d2", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "message_id": "msg_2", + "thread_id": "thread_2", + }, + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1,d2", + "--yes", + }, f, stdout) + if err != nil { + t.Fatalf("expected nil err on full success, got %v", err) + } + + data := decodeShortcutEnvelopeData(t, stdout) + if data["total"].(float64) != 2 { + t.Errorf("total = %v, want 2", data["total"]) + } + if data["success_count"].(float64) != 2 { + t.Errorf("success_count = %v, want 2", data["success_count"]) + } + if data["failure_count"].(float64) != 0 { + t.Errorf("failure_count = %v, want 0", data["failure_count"]) + } + sent, ok := data["sent"].([]interface{}) + if !ok || len(sent) != 2 { + t.Fatalf("sent[] missing or wrong size: %#v", data["sent"]) + } + if _, exists := data["failed"]; exists { + t.Errorf("failed[] should be omitted on full success; got %#v", data["failed"]) + } + first := sent[0].(map[string]interface{}) + if first["draft_id"] != "d1" || first["message_id"] != "msg_1" || first["thread_id"] != "thread_1" { + t.Errorf("first sent entry shape unexpected: %#v", first) + } +} + +// TestMailDraftSend_PartialFailure verifies that one recoverable per-draft +// failure does not abort the batch; the remaining drafts are attempted; both +// arrays are populated; and the call returns ExitAPI/"partial_failure". +func TestMailDraftSend_PartialFailure(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + stubDraftSend(reg, "d1", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"message_id": "msg_1"}, + }) + // Non-fatal code (not in the {auth, app_status, config, permission, network, + // 4013, 6007, 6008, 6009, 6010, 6013} set) → recoverable. + stubDraftSend(reg, "d2", map[string]interface{}{ + "code": 230001, + "msg": "draft not found or already sent", + }) + stubDraftSend(reg, "d3", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"message_id": "msg_3"}, + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1,d2,d3", + "--yes", + }, f, stdout) + if err == nil { + t.Fatal("expected partial_failure error, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Code != output.ExitAPI { + t.Errorf("Code = %d, want ExitAPI=%d", exitErr.Code, output.ExitAPI) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "partial_failure" { + t.Errorf("Detail.Type = %v, want partial_failure", exitErr.Detail) + } + + data := decodeShortcutEnvelopeData(t, stdout) + if data["total"].(float64) != 3 { + t.Errorf("total = %v, want 3", data["total"]) + } + if data["success_count"].(float64) != 2 { + t.Errorf("success_count = %v, want 2", data["success_count"]) + } + if data["failure_count"].(float64) != 1 { + t.Errorf("failure_count = %v, want 1", data["failure_count"]) + } + failed, ok := data["failed"].([]interface{}) + if !ok || len(failed) != 1 { + t.Fatalf("failed[] missing or wrong size: %#v", data["failed"]) + } + failedEntry := failed[0].(map[string]interface{}) + if failedEntry["draft_id"] != "d2" { + t.Errorf("failed entry draft_id = %v, want d2", failedEntry["draft_id"]) + } + if !strings.Contains(strings.ToLower(failedEntry["error"].(string)), "draft not found") { + t.Errorf("failed entry error should contain server msg, got %q", failedEntry["error"]) + } +} + +// TestMailDraftSend_StopOnError verifies --stop-on-error short-circuits at the +// first recoverable failure. d3 is intentionally NOT stubbed: if the loop +// kept going, the httpmock RoundTripper would return "no stub for POST +// /user_mailboxes/me/drafts/d3/send" and Execute would surface it. +func TestMailDraftSend_StopOnError(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + stubDraftSend(reg, "d1", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"message_id": "msg_1"}, + }) + stubDraftSend(reg, "d2", map[string]interface{}{ + "code": 230001, + "msg": "draft not found", + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1,d2,d3", + "--yes", + "--stop-on-error", + }, f, stdout) + if err == nil { + t.Fatal("expected partial_failure error, got nil") + } + + data := decodeShortcutEnvelopeData(t, stdout) + if data["success_count"].(float64) != 1 { + t.Errorf("success_count = %v, want 1", data["success_count"]) + } + if data["failure_count"].(float64) != 1 { + t.Errorf("failure_count = %v, want 1", data["failure_count"]) + } + if data["total"].(float64) != 3 { + t.Errorf("total = %v, want 3", data["total"]) + } +} + +// TestMailDraftSend_FatalAborts verifies that a fatal errno (mailbox not +// found) aborts the batch immediately and does NOT populate failed[]; the +// later drafts are not attempted (d2 is intentionally not stubbed — any +// attempt would be observable as a runner failure from the httpmock layer). +func TestMailDraftSend_FatalAborts(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + stubDraftSend(reg, "d1", map[string]interface{}{ + "code": output.LarkErrMailboxNotFound, + "msg": "mailbox not found", + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1,d2", + "--yes", + }, f, stdout) + if err == nil { + t.Fatal("expected fatal abort error, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if exitErr.Detail == nil || exitErr.Detail.Code != output.LarkErrMailboxNotFound { + t.Errorf("expected Detail.Code = %d, got %#v", output.LarkErrMailboxNotFound, exitErr.Detail) + } + // No JSON envelope on stdout because Execute returned early before rt.Out. + if stdout.Len() != 0 { + t.Errorf("expected no JSON output on fatal abort, got %s", stdout.String()) + } +} + +// TestMailDraftSend_AutomationDisabled verifies that an HTTP-success response +// carrying the automation_send_disable signal aborts the batch with +// ExitAPI/"automation_send_disabled" and does NOT continue to subsequent +// drafts (d2 intentionally has no stub — any attempt would surface as an +// httpmock "no stub" failure). +func TestMailDraftSend_AutomationDisabled(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + stubDraftSend(reg, "d1", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "message_id": "msg_1", + "automation_send_disable": map[string]interface{}{ + "reason": "policy: outbound automation disabled", + }, + }, + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1,d2", + "--yes", + }, f, stdout) + if err == nil { + t.Fatal("expected automation_send_disabled error, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if exitErr.Code != output.ExitAPI { + t.Errorf("Code = %d, want ExitAPI=%d", exitErr.Code, output.ExitAPI) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "automation_send_disabled" { + t.Errorf("Detail.Type = %v, want automation_send_disabled", exitErr.Detail) + } + if !strings.Contains(exitErr.Error(), "outbound automation disabled") { + t.Errorf("error message should propagate reason, got %q", exitErr.Error()) + } +} + +// TestMailDraftSend_ValidateErrors verifies that input-shape problems are +// caught in the pre-call layers (cobra Required + Validate). No network call +// is registered; the test should fail loudly if any HTTP call is attempted +// (httpmock returns "no stub" in that case). +func TestMailDraftSend_ValidateErrors(t *testing.T) { + cases := []struct { + name string + args []string + wantSub string + wantCobra bool // true → cobra-level MarkFlagRequired error path + }{ + { + name: "missing draft-id", + args: []string{"+draft-send", "--yes"}, + wantSub: `required flag(s) "draft-id" not set`, + wantCobra: true, + }, + { + // cobra's StringSlice treats a bare "" as an unset flag, so pass a + // whitespace-only element instead to drive the Validate-callback + // empty-element branch. + name: "whitespace-only value", + args: []string{"+draft-send", "--draft-id", " ", "--yes"}, + wantSub: "--draft-id contains empty value", + }, + { + name: "exceeds cap", + args: []string{"+draft-send", "--draft-id", manyDraftIDs(MaxBatchSendDrafts + 1), "--yes"}, + wantSub: "too many drafts", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailDraftSend, c.args, f, stdout) + if err == nil { + t.Fatalf("expected validation error, got nil") + } + if !strings.Contains(err.Error(), c.wantSub) { + t.Errorf("err = %v, want substring %q", err, c.wantSub) + } + }) + } +} + +// manyDraftIDs returns a CSV string with n synthesised IDs. Used to drive the +// >MaxBatchSendDrafts validation branch without bloating the test file with a +// hand-written list. +func manyDraftIDs(n int) string { + parts := make([]string, n) + for i := range parts { + parts[i] = "d" + strings.Repeat("x", 1) + intToString(i) + } + return strings.Join(parts, ",") +} + +// intToString avoids the strconv import noise for a tiny test helper. +func intToString(i int) string { + if i == 0 { + return "0" + } + var buf [20]byte + pos := len(buf) + for i > 0 { + pos-- + buf[pos] = byte('0' + i%10) + i /= 10 + } + return string(buf[pos:]) +} + +// TestMailDraftSend_MissingYes verifies the framework's high-risk-write +// confirmation gate triggers ExitConfirmationRequired (10) when --yes is +// omitted, before Execute is called. +func TestMailDraftSend_MissingYes(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1", + }, f, stdout) + if err == nil { + t.Fatal("expected ExitConfirmationRequired, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if exitErr.Code != output.ExitConfirmationRequired { + t.Errorf("Code = %d, want ExitConfirmationRequired=%d", exitErr.Code, output.ExitConfirmationRequired) + } +} + +// TestMailDraftSend_DryRun verifies --dry-run prints N POST calls in input +// order and does NOT touch the network. +func TestMailDraftSend_DryRun(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1,d2,d3", + "--yes", + "--dry-run", + }, f, stdout) + if err != nil { + t.Fatalf("dry-run failed: %v", err) + } + s := stdout.String() + for _, want := range []string{ + `/user_mailboxes/me/drafts/d1/send`, + `/user_mailboxes/me/drafts/d2/send`, + `/user_mailboxes/me/drafts/d3/send`, + `"method"`, + `"POST"`, + } { + if !strings.Contains(s, want) { + t.Errorf("dry-run output missing %q; got %s", want, s) + } + } +} + +// TestMailDraftSend_DryRunDirectInvocation drives dryRunDraftSend through a +// hand-built RuntimeContext so the dry-run plan can be inspected without the +// full Mount pipeline. Useful for catching path-encoding regressions in +// mailboxPath(). +func TestMailDraftSend_DryRunDirectInvocation(t *testing.T) { + rt := runtimeForMailDraftSendTest(t, map[string]string{ + "mailbox": "alice@example.com", + }, []string{"d1", "d2"}) + api := dryRunDraftSend(context.Background(), rt) + raw, err := json.Marshal(api) + if err != nil { + t.Fatalf("marshal dry-run failed: %v", err) + } + s := string(raw) + for _, want := range []string{ + `/user_mailboxes/alice@example.com/drafts/d1/send`, + `/user_mailboxes/alice@example.com/drafts/d2/send`, + `"method":"POST"`, + } { + if !strings.Contains(s, want) { + t.Errorf("dry-run JSON missing %q; got %s", want, s) + } + } +} + +// runtimeForMailDraftSendTest builds a minimal RuntimeContext with the +draft- +// send flag set so the DryRun callback can be exercised directly. Mirrors +// runtimeForMailDeclineReceiptDryRun. +func runtimeForMailDraftSendTest(t *testing.T, strFlags map[string]string, draftIDs []string) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("mailbox", "", "") + cmd.Flags().StringSlice("draft-id", nil, "") + cmd.Flags().Bool("stop-on-error", false, "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("parse flags failed: %v", err) + } + for k, v := range strFlags { + if err := cmd.Flags().Set(k, v); err != nil { + t.Fatalf("set flag --%s failed: %v", k, err) + } + } + for _, id := range draftIDs { + if err := cmd.Flags().Set("draft-id", id); err != nil { + t.Fatalf("set draft-id failed: %v", err) + } + } + return &common.RuntimeContext{Cmd: cmd} +} + +// TestMailDraftSend_MailboxFallback verifies that omitting --mailbox falls +// through to "me" via resolveComposeMailboxID, and the output reflects it. +func TestMailDraftSend_MailboxFallback(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + stubDraftSend(reg, "d1", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"message_id": "msg_1"}, + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1", + "--yes", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + data := decodeShortcutEnvelopeData(t, stdout) + if data["mailbox_id"] != "me" { + t.Errorf("mailbox_id = %v, want me (default)", data["mailbox_id"]) + } +} + +// TestMailDraftSend_RepeatedFlagAndCSV verifies that string_slice supports +// both the repeated-flag form (--draft-id d1 --draft-id d2) and the +// comma-separated form (--draft-id d1,d2) — and mixing both in one invocation. +func TestMailDraftSend_RepeatedFlagAndCSV(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + stubDraftSend(reg, "d1", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"message_id": "msg_1"}, + }) + stubDraftSend(reg, "d2", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"message_id": "msg_2"}, + }) + stubDraftSend(reg, "d3", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"message_id": "msg_3"}, + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1,d2", + "--draft-id", "d3", + "--yes", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + data := decodeShortcutEnvelopeData(t, stdout) + if data["success_count"].(float64) != 3 { + t.Errorf("success_count = %v, want 3", data["success_count"]) + } +} + +// TestIsFatalSendErr is a focused unit test for the classifier. Covers every +// branch documented in the doc comment so future tweaks immediately surface +// mis-categorisation. +func TestIsFatalSendErr(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + { + name: "nil-like / unknown shape → fatal", + err: errors.New("raw network panic surfaced unwrapped"), + want: true, + }, + { + name: "ExitError without Detail → fatal", + err: &output.ExitError{Code: output.ExitInternal}, + want: true, + }, + { + name: "auth → fatal", + err: &output.ExitError{ + Code: output.ExitAuth, + Detail: &output.ErrDetail{Type: "auth", Message: "token expired"}, + }, + want: true, + }, + { + name: "app_status → fatal", + err: &output.ExitError{ + Code: output.ExitAuth, + Detail: &output.ErrDetail{Type: "app_status", Message: "app disabled"}, + }, + want: true, + }, + { + name: "config → fatal", + err: &output.ExitError{ + Code: output.ExitAuth, + Detail: &output.ErrDetail{Type: "config", Message: "bad app_id"}, + }, + want: true, + }, + { + name: "permission → fatal", + err: &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{Type: "permission", Message: "denied"}, + }, + want: true, + }, + { + name: "ExitNetwork → fatal", + err: &output.ExitError{ + Code: output.ExitNetwork, + Detail: &output.ErrDetail{Type: "network", Message: "DNS timeout"}, + }, + want: true, + }, + { + name: "LarkErrMailboxNotFound → fatal", + err: &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrMailboxNotFound}, + }, + want: true, + }, + { + name: "LarkErrMailSendQuotaUser → fatal", + err: &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrMailSendQuotaUser}, + }, + want: true, + }, + { + name: "LarkErrTenantStorageLimit → fatal", + err: &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrTenantStorageLimit}, + }, + want: true, + }, + { + name: "generic api_error → recoverable", + err: &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{Type: "api_error", Code: 230001}, + }, + want: false, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := isFatalSendErr(c.err) + if got != c.want { + t.Errorf("isFatalSendErr(%s) = %v, want %v", c.name, got, c.want) + } + }) + } +} + +// TestExtractAutomationDisabledReason verifies all branches of the helper: +// missing key → "", malformed map → generic message, empty/whitespace reason +// → generic message, non-empty reason → trimmed value. +func TestExtractAutomationDisabledReason(t *testing.T) { + cases := []struct { + name string + in map[string]interface{} + want string + }{ + {"missing key", map[string]interface{}{"message_id": "x"}, ""}, + {"non-map value", map[string]interface{}{ + "automation_send_disable": "not a map", + }, "automation send disabled (no reason provided)"}, + {"map but no reason", map[string]interface{}{ + "automation_send_disable": map[string]interface{}{}, + }, "automation send disabled (no reason provided)"}, + {"reason empty", map[string]interface{}{ + "automation_send_disable": map[string]interface{}{"reason": " "}, + }, "automation send disabled (no reason provided)"}, + {"reason populated", map[string]interface{}{ + "automation_send_disable": map[string]interface{}{"reason": " policy block "}, + }, "policy block"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := extractAutomationDisabledReason(c.in) + if got != c.want { + t.Errorf("extractAutomationDisabledReason() = %q, want %q", got, c.want) + } + }) + } +} + +// Compile-time guard: stubDraftSendStatus must remain referenced. Reserved +// for an HTTP-status variant of failure stubs added by future tests. +var _ = stubDraftSendStatus diff --git a/shortcuts/mail/shortcuts.go b/shortcuts/mail/shortcuts.go index 8bd7a7f01..e0d8a9ee7 100644 --- a/shortcuts/mail/shortcuts.go +++ b/shortcuts/mail/shortcuts.go @@ -17,6 +17,7 @@ func Shortcuts() []common.Shortcut { MailReplyAll, MailSend, MailDraftCreate, + MailDraftSend, MailDraftEdit, MailForward, MailSendReceipt,