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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions shortcuts/mail/draft/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ func Send(runtime *common.RuntimeContext, mailboxID, draftID string) (map[string
return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, nil)
}

// SendWithBody sends a draft with an optional request body (e.g. for scheduled send).
// If body is nil, it behaves identically to Send.
func SendWithBody(runtime *common.RuntimeContext, mailboxID, draftID string, body map[string]interface{}) (map[string]interface{}, error) {
if len(body) == 0 {
return Send(runtime, mailboxID, draftID)
}
return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, body)
}

func extractDraftID(data map[string]interface{}) string {
if id, ok := data["draft_id"].(string); ok && strings.TrimSpace(id) != "" {
return strings.TrimSpace(id)
Expand Down
78 changes: 78 additions & 0 deletions shortcuts/mail/mail_cancel_scheduled_send.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package mail

import (
"context"
"fmt"
"net/url"

"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)

var MailCancelScheduledSend = common.Shortcut{
Service: "mail",
Command: "+cancel-scheduled-send",
Description: "Cancel a scheduled email send. The email will be restored as a draft.",
Risk: "write",
Scopes: []string{"mail:user_mailbox.message:send"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "message-id", Desc: "Message ID of the scheduled email to cancel (required)", Required: true},
{Name: "user-mailbox-id", Desc: "User mailbox ID (default: me)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("message-id") == "" {
return output.ErrValidation("--message-id is required")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageID := runtime.Str("message-id")
userMailboxID := runtime.Str("user-mailbox-id")
if userMailboxID == "" {
userMailboxID = "me"
}
return common.NewDryRunAPI().
Desc("Cancel scheduled send — message will be restored as draft").
POST(fmt.Sprintf("/open-apis/mail/v1/user_mailboxes/%s/messages/%s/cancel_scheduled_send",
url.PathEscape(userMailboxID),
url.PathEscape(messageID),
))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
messageID := runtime.Str("message-id")
userMailboxID := runtime.Str("user-mailbox-id")
if userMailboxID == "" {
userMailboxID = "me"
}

path := fmt.Sprintf("/open-apis/mail/v1/user_mailboxes/%s/messages/%s/cancel_scheduled_send",
url.PathEscape(userMailboxID),
url.PathEscape(messageID),
)

_, err := runtime.CallAPI("POST", path, nil, nil)
if err != nil {
return output.ErrWithHint(output.ExitAPI, "api_error",
fmt.Sprintf("Failed to cancel scheduled send for message %s", messageID),
"Ensure the message ID is correct and the email has not already been sent.",
)
}
Comment on lines +58 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Original API error discarded

When runtime.CallAPI returns an error, the original err is silently dropped and replaced with a generic hint. Other shortcuts in this package wrap the underlying error with fmt.Errorf("...: %w", err), which preserves the diagnostic message for debugging. Consider wrapping err here so that the actual API error code/message is still surfaced:

Suggested change
_, err := runtime.CallAPI("POST", path, nil, nil)
if err != nil {
return output.ErrWithHint(output.ExitAPI, "api_error",
fmt.Sprintf("Failed to cancel scheduled send for message %s", messageID),
"Ensure the message ID is correct and the email has not already been sent.",
)
}
_, err := runtime.CallAPI("POST", path, nil, nil)
if err != nil {
return output.ErrWithHint(output.ExitAPI, "api_error",
fmt.Sprintf("Failed to cancel scheduled send for message %s: %v", messageID, err),
"Ensure the message ID is correct and the email has not already been sent.",
)
}


runtime.Out(map[string]interface{}{
"message_id": messageID,
"status": "cancelled",
"restored_as_draft": true,
}, nil)

fmt.Fprintf(runtime.IO().ErrOut,
"tip: the message has been restored as a draft. Use lark-cli mail +draft-edit --id %s to edit.\n",
sanitizeForTerminal(messageID))
Comment on lines +72 to +74
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

draft_edit_file="$(rg -n --type=go 'Command:\s*"\+draft-edit"' | cut -d: -f1 | head -n1)"
if [ -z "${draft_edit_file}" ]; then
  echo "Could not find the +draft-edit shortcut definition" >&2
  exit 1
fi

echo "=== +draft-edit shortcut definition: ${draft_edit_file} ==="
sed -n '1,240p' "${draft_edit_file}"

echo
echo "=== mailbox-related flags/path usage in ${draft_edit_file} ==="
rg -n 'Name:\s*"user-mailbox-id"|mailboxPath\(|runtime\.Str\("user-mailbox-id"\)' "${draft_edit_file}"

Repository: larksuite/cli

Length of output: 12017


🏁 Script executed:

cat -n shortcuts/mail/mail_cancel_scheduled_send.go | sed -n '50,90p'

Repository: larksuite/cli

Length of output: 1093


🏁 Script executed:

rg -n "messageID|mailboxID|resolveComposeMailboxID" shortcuts/mail/mail_cancel_scheduled_send.go | head -20

Repository: larksuite/cli

Length of output: 368


🏁 Script executed:

# Search for similar hint patterns in mail shortcuts that might include mailbox context
rg -n "Use lark-cli mail" shortcuts/mail/ -A 1 -B 1

Repository: larksuite/cli

Length of output: 372


🏁 Script executed:

cat -n shortcuts/mail/mail_cancel_scheduled_send.go | sed -n '35,65p'

Repository: larksuite/cli

Length of output: 1335


🏁 Script executed:

rg -n "MailCancelScheduledSend\s*=" shortcuts/mail/mail_cancel_scheduled_send.go -A 30 | head -50

Repository: larksuite/cli

Length of output: 1378


🏁 Script executed:

rg -n 'Name:\s*"draft-id"|Name:\s*"mailbox"|Name:\s*"from"' shortcuts/mail/mail_draft_edit.go | head -10

Repository: larksuite/cli

Length of output: 548


Include mailbox context in the draft-edit hint and use the correct flag name.

Line 73 has two issues. The hint uses --id, but the correct flag is --draft-id. More critically, the hint omits the mailbox selector. When a user cancels scheduled send from a custom mailbox (e.g., --user-mailbox-id custom@example.com), the restored draft is placed in that mailbox. The hint should include the mailbox context so the follow-up command targets the correct mailbox; otherwise, +draft-edit defaults to me and the user won't find the restored draft.

The hint should include the mailbox context (captured from userMailboxID) using --mailbox or --from:

Suggested fix
fmt.Fprintf(runtime.IO().ErrOut,
	"tip: the message has been restored as a draft. Use lark-cli mail +draft-edit --draft-id %s --mailbox %s to edit.\n",
	sanitizeForTerminal(messageID), sanitizeForTerminal(userMailboxID))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/mail_cancel_scheduled_send.go` around lines 72 - 74, The
current fmt.Fprintf call that writes the tip to runtime.IO().ErrOut uses the
wrong flag (--id) and omits mailbox context; update the fmt.Fprintf invocation
(the one that calls sanitizeForTerminal(messageID)) to use --draft-id and
include the mailbox selector by passing sanitizeForTerminal(userMailboxID) and
adding the --mailbox (or --from) flag so the hint reads like: use lark-cli mail
+draft-edit --draft-id <messageID> --mailbox <userMailboxID>.


return nil
},
}
96 changes: 96 additions & 0 deletions shortcuts/mail/mail_cancel_scheduled_send_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package mail

import (
"errors"
"testing"

"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)

func TestCancelScheduledSend_Success(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
URL: "/user_mailboxes/me/messages/msg_sched_001/cancel_scheduled_send",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})

err := runMountedMailShortcut(t, MailCancelScheduledSend, []string{
"+cancel-scheduled-send", "--message-id", "msg_sched_001",
}, f, stdout)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}

data := decodeShortcutEnvelopeData(t, stdout)
if data["message_id"] != "msg_sched_001" {
t.Errorf("expected message_id msg_sched_001, got %v", data["message_id"])
}
if data["status"] != "cancelled" {
t.Errorf("expected status cancelled, got %v", data["status"])
}
if data["restored_as_draft"] != true {
t.Errorf("expected restored_as_draft true, got %v", data["restored_as_draft"])
}
}

func TestCancelScheduledSend_MissingMessageID(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailCancelScheduledSend, []string{
"+cancel-scheduled-send",
}, f, stdout)
if err == nil {
t.Fatal("expected error for missing --message-id, got nil")
}
}

func TestCancelScheduledSend_APIError(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
URL: "/user_mailboxes/me/messages/msg_invalid/cancel_scheduled_send",
Body: map[string]interface{}{
"code": 99991,
"msg": "message not found",
},
})

err := runMountedMailShortcut(t, MailCancelScheduledSend, []string{
"+cancel-scheduled-send", "--message-id", "msg_invalid",
}, f, stdout)
if err == nil {
t.Fatal("expected error for API failure, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T: %v", err, err)
}
}

func TestCancelScheduledSend_CustomMailboxID(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
URL: "/user_mailboxes/mailbox_abc/messages/msg_sched_002/cancel_scheduled_send",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})

err := runMountedMailShortcut(t, MailCancelScheduledSend, []string{
"+cancel-scheduled-send", "--message-id", "msg_sched_002", "--user-mailbox-id", "mailbox_abc",
}, f, stdout)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}

data := decodeShortcutEnvelopeData(t, stdout)
if data["message_id"] != "msg_sched_002" {
t.Errorf("expected message_id msg_sched_002, got %v", data["message_id"])
}
}
36 changes: 33 additions & 3 deletions shortcuts/mail/mail_forward.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ var MailForward = common.Shortcut{
{Name: "attach", Desc: "Attachment file path(s), comma-separated, appended after original attachments (relative path only)"},
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
{Name: "confirm-send", Type: "bool", Desc: "Send the forward immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
{Name: "send-time", Desc: "Schedule the forward to be sent at a future time (RFC 3339 format, e.g. 2026-04-14T09:00:00+08:00). Requires --confirm-send to actually schedule."},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
Expand Down Expand Up @@ -64,6 +65,11 @@ var MailForward = common.Shortcut{
return err
}
}
if sendTimeStr := runtime.Str("send-time"); sendTimeStr != "" {
if _, err := parseAndValidateSendTime(sendTimeStr); err != nil {
return err
}
}
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
Expand All @@ -76,6 +82,7 @@ var MailForward = common.Shortcut{
attachFlag := runtime.Str("attach")
inlineFlag := runtime.Str("inline")
confirmSend := runtime.Bool("confirm-send")
sendTimeStr := runtime.Str("send-time")

mailboxID := resolveComposeMailboxID(runtime)
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
Expand Down Expand Up @@ -216,16 +223,39 @@ var MailForward = common.Shortcut{
"tip": fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftID),
}, nil)
hintSendDraft(runtime, mailboxID, draftID)
if sendTimeStr != "" {
fmt.Fprintf(runtime.IO().ErrOut,
"tip: --send-time was specified but --confirm-send was not. To schedule send, add --confirm-send.\n")
}
return nil
}
resData, err := draftpkg.Send(runtime, mailboxID, draftID)

var sendBody map[string]interface{}
if sendTimeStr != "" {
validatedTime, timeErr := parseAndValidateSendTime(sendTimeStr)
if timeErr != nil {
return timeErr
}
sendBody = map[string]interface{}{
"send_time": validatedTime,
}
}
resData, err := draftpkg.SendWithBody(runtime, mailboxID, draftID, sendBody)
if err != nil {
return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftID, err)
}
runtime.Out(map[string]interface{}{
outData := map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}, nil)
}
if sendTimeStr != "" {
outData["status"] = "scheduled"
outData["send_time"] = sendTimeStr
fmt.Fprintf(runtime.IO().ErrOut,
"tip: to cancel scheduled send: lark-cli mail +cancel-scheduled-send --message-id %s\n",
sanitizeForTerminal(fmt.Sprintf("%v", resData["message_id"])))
}
runtime.Out(outData, nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},
Expand Down
36 changes: 33 additions & 3 deletions shortcuts/mail/mail_reply.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ var MailReply = common.Shortcut{
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
{Name: "send-time", Desc: "Schedule the reply to be sent at a future time (RFC 3339 format, e.g. 2026-04-14T09:00:00+08:00). Requires --confirm-send to actually schedule."},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
Expand All @@ -56,6 +57,11 @@ var MailReply = common.Shortcut{
if err := validateConfirmSendScope(runtime); err != nil {
return err
}
if sendTimeStr := runtime.Str("send-time"); sendTimeStr != "" {
if _, err := parseAndValidateSendTime(sendTimeStr); err != nil {
return err
}
}
return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
Expand All @@ -68,6 +74,7 @@ var MailReply = common.Shortcut{
attachFlag := runtime.Str("attach")
inlineFlag := runtime.Str("inline")
confirmSend := runtime.Bool("confirm-send")
sendTimeStr := runtime.Str("send-time")

inlineSpecs, err := parseInlineSpecs(inlineFlag)
if err != nil {
Expand Down Expand Up @@ -179,16 +186,39 @@ var MailReply = common.Shortcut{
"tip": fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftID),
}, nil)
hintSendDraft(runtime, mailboxID, draftID)
if sendTimeStr != "" {
fmt.Fprintf(runtime.IO().ErrOut,
"tip: --send-time was specified but --confirm-send was not. To schedule send, add --confirm-send.\n")
}
return nil
}
resData, err := draftpkg.Send(runtime, mailboxID, draftID)

var sendBody map[string]interface{}
if sendTimeStr != "" {
validatedTime, timeErr := parseAndValidateSendTime(sendTimeStr)
if timeErr != nil {
return timeErr
}
sendBody = map[string]interface{}{
"send_time": validatedTime,
}
}
resData, err := draftpkg.SendWithBody(runtime, mailboxID, draftID, sendBody)
if err != nil {
return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftID, err)
}
runtime.Out(map[string]interface{}{
outData := map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}, nil)
}
if sendTimeStr != "" {
outData["status"] = "scheduled"
outData["send_time"] = sendTimeStr
fmt.Fprintf(runtime.IO().ErrOut,
"tip: to cancel scheduled send: lark-cli mail +cancel-scheduled-send --message-id %s\n",
sanitizeForTerminal(fmt.Sprintf("%v", resData["message_id"])))
}
runtime.Out(outData, nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},
Expand Down
Loading
Loading