-
Notifications
You must be signed in to change notification settings - Fork 823
feat(mail): add +draft-send shortcut for batch draft sending #1017
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
xukuncx
wants to merge
1
commit into
larksuite:main
Choose a base branch
from
xukuncx:feat/cf467ea
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+960
−0
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,255 @@ | ||
| // Copyright (c) 2026 Lark Technologies Pte. Ltd. | ||
| // SPDX-License-Identifier: MIT | ||
|
|
||
| package mail | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "strings" | ||
|
|
||
| "github.com/larksuite/cli/internal/output" | ||
| "github.com/larksuite/cli/shortcuts/common" | ||
| ) | ||
|
|
||
| // MaxBatchSendDrafts caps the number of draft IDs accepted in a single | ||
| // +draft-send invocation. The limit is purely client-side: it bounds command- | ||
| // line length comfortably below ARG_MAX and keeps the failure blast radius of | ||
| // a single batch small. It is intentionally local to this shortcut (rather | ||
| // than living in limits.go) because no other shortcut shares the semantics. | ||
| const MaxBatchSendDrafts = 50 | ||
|
|
||
| // sentDraft is the per-draft success entry in the +draft-send aggregated | ||
| // output. message_id and thread_id come from the server response of | ||
| // POST /drafts/:draft_id/send. | ||
| type sentDraft struct { | ||
| DraftID string `json:"draft_id"` | ||
| MessageID string `json:"message_id"` | ||
| ThreadID string `json:"thread_id,omitempty"` | ||
| } | ||
|
|
||
| // failedDraft is the per-draft failure entry. error is the | ||
| // human-readable err.Error() string (typically including ClassifyLarkError | ||
| // hints); v2 may surface a structured errno field separately once the server- | ||
| // side mapping stabilises (see tech-design "待确认事项"). | ||
| type failedDraft struct { | ||
| DraftID string `json:"draft_id"` | ||
| Error string `json:"error"` | ||
| } | ||
|
|
||
| // batchSendOutput is the JSON envelope data shape: | ||
| // | ||
| // { | ||
| // "mailbox_id": "me", | ||
| // "total": 3, | ||
| // "success_count": 2, | ||
| // "failure_count": 1, | ||
| // "sent": [{"draft_id":..., "message_id":..., "thread_id":...}, ...], | ||
| // "failed":[{"draft_id":..., "error":...}] | ||
| // } | ||
| // | ||
| // failed is marked omitempty so a fully successful batch returns a clean shape | ||
| // without an empty array. | ||
| type batchSendOutput struct { | ||
| MailboxID string `json:"mailbox_id"` | ||
| Total int `json:"total"` | ||
| SuccessCount int `json:"success_count"` | ||
| FailureCount int `json:"failure_count"` | ||
| Sent []sentDraft `json:"sent"` | ||
| Failed []failedDraft `json:"failed,omitempty"` | ||
| } | ||
|
|
||
| // MailDraftSend is the `+draft-send` shortcut: send N existing drafts | ||
| // sequentially via POST /drafts/:draft_id/send, isolating per-draft failures. | ||
| // Risk is "high-risk-write"; callers must pass --yes. User identity only — | ||
| // drafts are user-owned resources and bot has no coherent semantics here. | ||
| // | ||
| // Output schema is the batchSendOutput type above. Partial failures (any | ||
| // failed[]) return exit 1 with envelope.error.type="partial_failure" so that | ||
| // agents can distinguish "all sent" from "some sent" without parsing the | ||
| // success_count field. | ||
| var MailDraftSend = common.Shortcut{ | ||
| Service: "mail", | ||
| Command: "+draft-send", | ||
| Description: "Send one or more existing mail drafts sequentially. Calls " + | ||
| "POST /drafts/:draft_id/send for each input ID, isolates per-draft " + | ||
| "failures, and aggregates the results. Use after the drafts have " + | ||
| "already been created (via the Lark client, +draft-create, or the " + | ||
| "drafts.create API).", | ||
| Risk: "high-risk-write", | ||
| Scopes: []string{"mail:user_mailbox.message:send"}, | ||
| AuthTypes: []string{"user"}, | ||
| HasFormat: true, | ||
| Flags: []common.Flag{ | ||
| {Name: "mailbox", Desc: "Mailbox email address that owns the drafts (default: me)."}, | ||
| {Name: "draft-id", Type: "string_slice", Required: true, | ||
| Desc: "Draft IDs to send; comma-separated or repeat the flag (max 50)."}, | ||
| {Name: "stop-on-error", Type: "bool", | ||
| Desc: "Stop at the first recoverable per-draft failure (default: continue and aggregate). " + | ||
| "Fatal errors (auth, permission, network, mailbox-level quota) always abort immediately " + | ||
| "regardless of this flag."}, | ||
| }, | ||
| DryRun: dryRunDraftSend, | ||
| Execute: executeDraftSend, | ||
| } | ||
|
|
||
| // executeDraftSend runs the +draft-send command: | ||
| // | ||
| // 1. Resolve mailbox ID (defaults to "me" via resolveComposeMailboxID). | ||
| // 2. Validate the draft-id slice (non-empty, under MaxBatchSendDrafts cap, | ||
| // no empty elements). | ||
| // 3. Loop over each draft ID, calling POST .../drafts/:id/send directly via | ||
| // runtime.CallAPI. Per-draft outcomes: | ||
| // - fatal err (isFatalSendErr) → return immediately (bypasses --stop-on-error). | ||
| // - recoverable err → append to failed[]; honor --stop-on-error. | ||
| // - success + automation_send_disable signal → return immediately with | ||
| // ExitAPI/"automation_send_disabled". | ||
| // - success → append to sent[]. | ||
| // 4. Emit batchSendOutput via runtime.Out. | ||
| // 5. If any draft failed, return ExitAPI/"partial_failure" so exit code = 1. | ||
| func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error { | ||
| mailboxID := resolveComposeMailboxID(rt) | ||
| draftIDs := rt.StrSlice("draft-id") | ||
|
|
||
| if len(draftIDs) == 0 { | ||
| return output.ErrValidation("--draft-id is required") | ||
| } | ||
| if len(draftIDs) > MaxBatchSendDrafts { | ||
| return output.ErrValidation( | ||
| "too many drafts: %d > %d (split into multiple batches)", | ||
| len(draftIDs), MaxBatchSendDrafts) | ||
| } | ||
| for _, id := range draftIDs { | ||
| if strings.TrimSpace(id) == "" { | ||
| return output.ErrValidation("--draft-id contains empty value") | ||
| } | ||
| } | ||
|
|
||
| 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)" | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Trim draft IDs before building API paths.
Line 124 validates
TrimSpace(id)but the request path still uses untrimmedid, so whitespace-padded CSV values can slip through and hit incorrect endpoints.Proposed fix
Also applies to: 131-135
🤖 Prompt for AI Agents