From ab94ee9f549643d2392e756f7a83e964bb7a3eee Mon Sep 17 00:00:00 2001 From: xukuncx Date: Wed, 27 May 2026 18:12:41 +0800 Subject: [PATCH] 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. --- internal/output/lark_errors.go | 13 + internal/output/lark_errors_test.go | 26 + shortcuts/mail/mail_draft_send.go | 330 ++++++ shortcuts/mail/mail_draft_send_test.go | 942 ++++++++++++++++++ shortcuts/mail/shortcuts.go | 1 + tests/cli_e2e/mail/coverage.md | 8 +- .../mail/mail_draft_send_dryrun_test.go | 124 +++ .../mail/mail_draft_send_workflow_test.go | 166 +++ 8 files changed, 1607 insertions(+), 3 deletions(-) create mode 100644 shortcuts/mail/mail_draft_send.go create mode 100644 shortcuts/mail/mail_draft_send_test.go create mode 100644 tests/cli_e2e/mail/mail_draft_send_dryrun_test.go create mode 100644 tests/cli_e2e/mail/mail_draft_send_workflow_test.go diff --git a/internal/output/lark_errors.go b/internal/output/lark_errors.go index 83ecda9f..62a5e057 100644 --- a/internal/output/lark_errors.go +++ b/internal/output/lark_errors.go @@ -66,6 +66,19 @@ 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. + // Mail v1 uses service-scoped 123xxxx codes; keep the full upstream code + // because ErrAPI preserves Detail.Code exactly as returned by the server. + // These codes indicate the entire batch will keep failing identically and + // are consumed by shortcuts/mail.isFatalSendErr to abort early. + LarkErrMailboxNotFound = 1234013 // mailbox not found or not active + LarkErrMailSendQuotaUser = 1236007 // user daily send count exceeded + LarkErrMailSendQuotaUserExt = 1236008 // user daily external recipient count exceeded + LarkErrMailSendQuotaTenantExt = 1236009 // tenant daily external recipient count exceeded + LarkErrMailQuota = 1236010 // mail quota limit + LarkErrTenantStorageLimit = 1236013 // tenant storage limit exceeded ) // legacyHints supplies the per-code actionable hint string for the legacy diff --git a/internal/output/lark_errors_test.go b/internal/output/lark_errors_test.go index 3e8c0e67..9f7fae8d 100644 --- a/internal/output/lark_errors_test.go +++ b/internal/output/lark_errors_test.go @@ -91,6 +91,32 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) { } } +func TestMailSendErrorConstantsUseServiceScopedCodes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + got int + want int + }{ + {name: "mailbox not found", got: LarkErrMailboxNotFound, want: 1234013}, + {name: "user daily send quota", got: LarkErrMailSendQuotaUser, want: 1236007}, + {name: "user external recipient quota", got: LarkErrMailSendQuotaUserExt, want: 1236008}, + {name: "tenant external recipient quota", got: LarkErrMailSendQuotaTenantExt, want: 1236009}, + {name: "mail quota", got: LarkErrMailQuota, want: 1236010}, + {name: "tenant storage limit", got: LarkErrTenantStorageLimit, want: 1236013}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if tt.got != tt.want { + t.Fatalf("code=%d, want %d", tt.got, tt.want) + } + }) + } +} + // TestClassifyLarkError_WikiLockContention verifies the wiki write-lock // contention error (131009) maps to an actionable retry hint instead of // a generic "api_error". Surfaces during concurrent wiki +node-create diff --git a/shortcuts/mail/mail_draft_send.go b/shortcuts/mail/mail_draft_send.go new file mode 100644 index 00000000..e91c5593 --- /dev/null +++ b/shortcuts/mail/mail_draft_send.go @@ -0,0 +1,330 @@ +// 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)" +} diff --git a/shortcuts/mail/mail_draft_send_test.go b/shortcuts/mail/mail_draft_send_test.go new file mode 100644 index 00000000..936d0574 --- /dev/null +++ b/shortcuts/mail/mail_draft_send_test.go @@ -0,0 +1,942 @@ +// 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 +} + +// 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_ProgressWritesToStderr verifies long sends do not look +// hung: per-draft progress is emitted on stderr while stdout remains the +// final machine-readable JSON ledger. +func TestMailDraftSend_ProgressWritesToStderr(t *testing.T) { + f, stdout, stderr, 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", + }) + 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") + } + + progress := stderr.String() + for _, want := range []string{ + "mail +draft-send: [1/3] sending draft d1", + "mail +draft-send: [1/3] sent draft d1 message_id=msg_1", + "mail +draft-send: [2/3] sending draft d2", + "mail +draft-send: [2/3] failed draft d2:", + "mail +draft-send: [3/3] sending draft d3", + "mail +draft-send: [3/3] sent draft d3 message_id=msg_3", + } { + if !strings.Contains(progress, want) { + t.Errorf("stderr missing %q; got %s", want, progress) + } + } + if strings.Contains(stdout.String(), "mail +draft-send:") { + t.Errorf("stdout must not contain progress lines; got %s", stdout.String()) + } + data := decodeShortcutEnvelopeData(t, stdout) + if data["success_count"].(float64) != 2 || data["failure_count"].(float64) != 1 { + t.Errorf("unexpected aggregate counts: %#v", data) + } +} + +// 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, 1234013, 1236007, 1236008, 1236009, 1236010, 1236013} + // 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_FatalAfterSuccessEmitsLedger verifies that a fatal error +// after earlier side effects still emits the aggregate stdout ledger before +// returning the fatal stderr error. This lets callers avoid blindly retrying a +// draft that was already sent. +func TestMailDraftSend_FatalAfterSuccessEmitsLedger(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": output.LarkErrMailSendQuotaUser, + "msg": "user daily send count exceeded", + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1,d2,d3", + "--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.LarkErrMailSendQuotaUser { + t.Errorf("expected Detail.Code = %d, got %#v", output.LarkErrMailSendQuotaUser, exitErr.Detail) + } + + data := decodeShortcutEnvelopeData(t, stdout) + if data["total"].(float64) != 3 { + t.Errorf("total = %v, want 3", data["total"]) + } + 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 got := gjsonLikeString(t, data, "sent", 0, "draft_id"); got != "d1" { + t.Errorf("sent[0].draft_id = %q, want d1", got) + } + if got := gjsonLikeString(t, data, "failed", 0, "draft_id"); got != "d2" { + t.Errorf("failed[0].draft_id = %q, want d2", got) + } +} + +// 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_AutomationDisabledAfterSuccessEmitsLedger verifies that an +// automation-send policy stop after earlier successful sends still writes the +// batch ledger to stdout before returning the structured fatal error. +func TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger(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", + "automation_send_disable": map[string]interface{}{ + "reason": "policy: outbound automation disabled", + }, + }, + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", "d1,d2,d3", + "--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.Detail == nil || exitErr.Detail.Type != "automation_send_disabled" { + t.Errorf("Detail.Type = %v, want automation_send_disabled", exitErr.Detail) + } + + data := decodeShortcutEnvelopeData(t, stdout) + if data["total"].(float64) != 3 { + t.Errorf("total = %v, want 3", data["total"]) + } + 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 got := gjsonLikeString(t, data, "sent", 0, "draft_id"); got != "d1" { + t.Errorf("sent[0].draft_id = %q, want d1", got) + } + if got := gjsonLikeString(t, data, "failed", 0, "draft_id"); got != "d2" { + t.Errorf("failed[0].draft_id = %q, want d2", got) + } + if got := gjsonLikeString(t, data, "failed", 0, "error"); !strings.Contains(got, "outbound automation disabled") { + t.Errorf("failed[0].error should contain reason, got %q", got) + } +} + +// 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", + }, + { + name: "duplicate value", + args: []string{"+draft-send", "--draft-id", "d1,d2,d1", "--yes"}, + wantSub: "--draft-id contains duplicate value: d1", + }, + } + 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) + } + }) + } +} + +func TestMailDraftSend_DryRunValidateErrors(t *testing.T) { + cases := []struct { + name string + args []string + wantSub string + }{ + { + name: "whitespace-only value", + args: []string{"+draft-send", "--draft-id", " ", "--dry-run"}, + wantSub: "--draft-id contains empty value", + }, + { + name: "exceeds cap", + args: []string{"+draft-send", "--draft-id", manyDraftIDs(MaxBatchSendDrafts + 1), "--dry-run"}, + wantSub: "too many drafts", + }, + { + name: "duplicate value", + args: []string{"+draft-send", "--draft-id", "d1,d2,d1", "--dry-run"}, + wantSub: "--draft-id contains duplicate value: d1", + }, + } + 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) + } + if stdout.Len() != 0 { + t.Errorf("expected no dry-run output on validation error, got %s", stdout.String()) + } + }) + } +} + +// 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 ", + "--draft-id", " 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_NormalizesDraftIDs verifies request paths and output use +// trimmed draft IDs rather than preserving CLI whitespace. +func TestMailDraftSend_NormalizesDraftIDs(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"}, + }) + + err := runMountedMailShortcut(t, MailDraftSend, []string{ + "+draft-send", + "--draft-id", " d1 , d2 ", + "--yes", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + data := decodeShortcutEnvelopeData(t, stdout) + if got := gjsonLikeString(t, data, "sent", 0, "draft_id"); got != "d1" { + t.Errorf("sent[0].draft_id = %q, want d1", got) + } + if got := gjsonLikeString(t, data, "sent", 1, "draft_id"); got != "d2" { + t.Errorf("sent[1].draft_id = %q, want d2", got) + } +} + +// 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: "rate_limit → fatal", + err: &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{Type: "rate_limit", Code: output.LarkErrRateLimit}, + }, + want: true, + }, + { + name: "ExitNetwork → fatal", + err: &output.ExitError{ + Code: output.ExitNetwork, + Detail: &output.ErrDetail{Type: "network", Message: "DNS timeout"}, + }, + want: true, + }, + { + name: "wrapped ExitNetwork → fatal", + err: output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", output.ErrNetwork("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) + } + }) + } +} + +func gjsonLikeString(t *testing.T, data map[string]interface{}, arrayKey string, index int, field string) string { + t.Helper() + items, ok := data[arrayKey].([]interface{}) + if !ok { + t.Fatalf("%s missing or wrong type: %#v", arrayKey, data[arrayKey]) + } + if index >= len(items) { + t.Fatalf("%s[%d] missing; len=%d", arrayKey, index, len(items)) + } + item, ok := items[index].(map[string]interface{}) + if !ok { + t.Fatalf("%s[%d] wrong type: %#v", arrayKey, index, items[index]) + } + value, ok := item[field].(string) + if !ok { + t.Fatalf("%s[%d].%s missing or wrong type: %#v", arrayKey, index, field, item[field]) + } + return value +} diff --git a/shortcuts/mail/shortcuts.go b/shortcuts/mail/shortcuts.go index 8bd7a7f0..e0d8a9ee 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, diff --git a/tests/cli_e2e/mail/coverage.md b/tests/cli_e2e/mail/coverage.md index f4839444..2910238f 100644 --- a/tests/cli_e2e/mail/coverage.md +++ b/tests/cli_e2e/mail/coverage.md @@ -1,12 +1,13 @@ # Mail CLI E2E Coverage ## Metrics -- Denominator: 62 leaf commands -- Covered: 13 -- Coverage: 21.0% +- Denominator: 63 leaf commands +- Covered: 14 +- Coverage: 22.2% ## Summary - TestMail_DraftLifecycleWorkflowAsUser: proves a self-contained user draft workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail user_mailbox.drafts list`, `mail user_mailbox.drafts get`, `mail +draft-edit`, and `mail user_mailbox.drafts delete`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create draft with shortcut as user`, `list draft as user`, `get created draft as user`, `inspect created draft as user`, `update draft subject with shortcut as user`, `inspect updated draft as user`, `delete draft as user`, and `verify draft removed from list as user`. +- TestMail_DraftSendWorkflowAsUser: proves a self-contained user draft-send workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail +draft-send`, and `mail +triage`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create self-addressed draft as user`, `send draft with shortcut as user`, and `find self-received message for cleanup`. - TestMail_SendWorkflowAsUser: proves a self-contained self-mail workflow across `mail +send`, `mail +triage`, `mail +message`, `mail +messages`, `mail +thread`, `mail +reply`, and `mail +forward`; key `t.Run(...)` proof points are `send mail to self with shortcut as user`, `find self sent mail in triage as user`, `get sent message as user`, `get received message as user`, `get both self sent messages as user`, `get self send thread as user`, `reply to received message with shortcut as user`, `inspect reply draft as user`, `forward received message with shortcut as user`, and `inspect forward draft as user`. - Blocked area: `mail +reply-all` is still uncovered because the self-send workflow produces only self-recipient traffic and reply-all’s recipient expansion becomes degenerate after self-address exclusion; `+signature`, `+watch`, event commands, and many raw message/thread mutation APIs still need dedicated tenant-aware workflows. @@ -16,6 +17,7 @@ | --- | --- | --- | --- | --- | --- | | ✓ | mail +draft-create | shortcut | mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/create draft with shortcut as user | `--subject`; `--body`; `--plain-text` | creates a new self-owned draft without relying on external recipients | | ✓ | mail +draft-edit | shortcut | mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/inspect created draft as user; mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/update draft subject with shortcut as user; mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/inspect updated draft as user | `--draft-id`; `--mailbox me`; `--inspect`; `--set-subject` | shortcut proves readback projection and subject update | +| ✓ | mail +draft-send | shortcut | mail_draft_send_workflow_test.go::TestMail_DraftSendWorkflowAsUser/send draft with shortcut as user; mail_draft_send_dryrun_test.go::TestMail_DraftSendDryRun | `--draft-id`; `--mailbox me`; `--yes`; dry-run repeated/comma-separated `--draft-id` | sends a self-addressed draft through the batch shortcut and locks dry-run request shape | | ✓ | mail +forward | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/forward received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect forward draft as user | `--message-id`; `--to`; `--body`; `--plain-text` | uses self-generated inbox message as source and inspects forwarded draft projection | | ✓ | mail +message | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get sent message as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get received message as user | `--mailbox me`; `--message-id` | verifies both SENT and INBOX copies after self-send | | ✓ | mail +messages | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get both self sent messages as user | `--mailbox me`; `--message-ids` | batch reads both sent and received message copies | diff --git a/tests/cli_e2e/mail/mail_draft_send_dryrun_test.go b/tests/cli_e2e/mail/mail_draft_send_dryrun_test.go new file mode 100644 index 00000000..4816f0a2 --- /dev/null +++ b/tests/cli_e2e/mail/mail_draft_send_dryrun_test.go @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "strconv" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestMail_DraftSendDryRun(t *testing.T) { + setMailDraftSendDryRunEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "mail", "+draft-send", + "--mailbox", "alias@example.com", + "--draft-id", " draft_001, draft_002 ", + "--draft-id", " draft_003 ", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + wantURLs := []string{ + "/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_001/send", + "/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_002/send", + "/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_003/send", + } + assert.Equal(t, int64(len(wantURLs)), gjson.Get(result.Stdout, "api.#").Int(), "stdout:\n%s", result.Stdout) + for i, wantURL := range wantURLs { + idx := strconv.Itoa(i) + assert.Equal(t, "POST", gjson.Get(result.Stdout, "api."+idx+".method").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, wantURL, gjson.Get(result.Stdout, "api."+idx+".url").String(), "stdout:\n%s", result.Stdout) + assert.False(t, gjson.Get(result.Stdout, "api."+idx+".body").Exists(), "stdout:\n%s", result.Stdout) + } +} + +func TestMail_DraftSendDryRunValidation(t *testing.T) { + setMailDraftSendDryRunEnv(t) + + tests := []struct { + name string + args []string + wantMsg string + }{ + { + name: "reject whitespace draft id", + args: []string{ + "mail", "+draft-send", + "--draft-id", " ", + "--dry-run", + }, + wantMsg: "--draft-id contains empty value", + }, + { + name: "reject too many draft ids", + args: []string{ + "mail", "+draft-send", + "--draft-id", manyDraftIDsForE2E(51), + "--dry-run", + }, + wantMsg: "too many drafts", + }, + { + name: "reject duplicate draft id", + args: []string{ + "mail", "+draft-send", + "--draft-id", "draft_001,draft_002,draft_001", + "--dry-run", + }, + wantMsg: "--draft-id contains duplicate value: draft_001", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + output := result.Stdout + result.Stderr + assert.Contains(t, output, tt.wantMsg, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + }) + } +} + +func setMailDraftSendDryRunEnv(t *testing.T) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "mail_draft_send_dryrun_test") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "mail_draft_send_dryrun_secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") +} + +func manyDraftIDsForE2E(n int) string { + ids := make([]byte, 0, n*4) + for i := 0; i < n; i++ { + if i > 0 { + ids = append(ids, ',') + } + ids = append(ids, 'd') + ids = strconv.AppendInt(ids, int64(i), 10) + } + return string(ids) +} diff --git a/tests/cli_e2e/mail/mail_draft_send_workflow_test.go b/tests/cli_e2e/mail/mail_draft_send_workflow_test.go new file mode 100644 index 00000000..11e971b0 --- /dev/null +++ b/tests/cli_e2e/mail/mail_draft_send_workflow_test.go @@ -0,0 +1,166 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestMail_DraftSendWorkflowAsUser(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + t.Cleanup(cancel) + + clie2e.SkipWithoutUserToken(t) + + const mailboxID = "me" + suffix := clie2e.GenerateSuffix() + subject := "lark-cli-e2e-mail-draft-send-" + suffix + body := "draft-send workflow body " + suffix + + var primaryEmail string + var draftID string + var draftSent bool + var sentMessageID string + var inboxMessageID string + + parentT.Cleanup(func() { + if draftID != "" && !draftSent { + cleanupCtx, cancel := clie2e.CleanupContext() + defer cancel() + + result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"mail", "user_mailbox.drafts", "delete"}, + DefaultAs: "user", + Params: map[string]any{ + "user_mailbox_id": mailboxID, + "draft_id": draftID, + }, + Yes: true, + }) + clie2e.ReportCleanupFailure(parentT, "delete draft "+draftID, result, err) + } + + var messageIDs []string + if sentMessageID != "" { + messageIDs = append(messageIDs, sentMessageID) + } + if inboxMessageID != "" && inboxMessageID != sentMessageID { + messageIDs = append(messageIDs, inboxMessageID) + } + if len(messageIDs) == 0 { + return + } + + cleanupCtx, cancel := clie2e.CleanupContext() + defer cancel() + + result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"mail", "user_mailbox.messages", "batch_trash"}, + DefaultAs: "user", + Params: map[string]any{"user_mailbox_id": mailboxID}, + Data: map[string]any{"message_ids": messageIDs}, + }) + clie2e.ReportCleanupFailure(parentT, "trash draft-send messages", result, err) + }) + + t.Run("get mailbox profile as user", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"mail", "user_mailboxes", "profile"}, + DefaultAs: "user", + Params: map[string]any{"user_mailbox_id": mailboxID}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + primaryEmail = gjson.Get(result.Stdout, "data.primary_email_address").String() + require.NotEmpty(t, primaryEmail, "stdout:\n%s", result.Stdout) + }) + + t.Run("create self-addressed draft as user", func(t *testing.T) { + require.NotEmpty(t, primaryEmail, "mailbox profile should be loaded before draft create") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "mail", "+draft-create", + "--to", primaryEmail, + "--subject", subject, + "--body", body, + "--plain-text", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + draftID = gjson.Get(result.Stdout, "data.draft_id").String() + require.NotEmpty(t, draftID, "stdout:\n%s", result.Stdout) + }) + + t.Run("send draft with shortcut as user", func(t *testing.T) { + require.NotEmpty(t, draftID, "draft should be created before +draft-send") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "mail", "+draft-send", + "--mailbox", mailboxID, + "--draft-id", draftID, + }, + DefaultAs: "user", + Yes: true, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + assert.Equal(t, int64(1), gjson.Get(result.Stdout, "data.total").Int(), "stdout:\n%s", result.Stdout) + assert.Equal(t, int64(1), gjson.Get(result.Stdout, "data.success_count").Int(), "stdout:\n%s", result.Stdout) + assert.Equal(t, int64(0), gjson.Get(result.Stdout, "data.failure_count").Int(), "stdout:\n%s", result.Stdout) + assert.Equal(t, draftID, gjson.Get(result.Stdout, "data.sent.0.draft_id").String(), "stdout:\n%s", result.Stdout) + + sentMessageID = gjson.Get(result.Stdout, "data.sent.0.message_id").String() + require.NotEmpty(t, sentMessageID, "stdout:\n%s", result.Stdout) + draftSent = true + }) + + t.Run("find self-received message for cleanup", func(t *testing.T) { + require.NotEmpty(t, sentMessageID, "draft should be sent before triage lookup") + + for attempt := 0; attempt < 12; attempt++ { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "mail", "+triage", + "--mailbox", mailboxID, + "--query", subject, + "--max", "10", + "--format", "data", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + for _, item := range gjson.Get(result.Stdout, "messages").Array() { + if item.Get("subject").String() != subject { + continue + } + messageID := item.Get("message_id").String() + if messageID != "" && messageID != sentMessageID { + inboxMessageID = messageID + return + } + } + time.Sleep(2 * time.Second) + } + }) +}