From d68c5220b5f27c6d4202ae7ea0c63fa2822d3038 Mon Sep 17 00:00:00 2001 From: "fengzhihao.infeng" Date: Mon, 13 Apr 2026 21:06:36 +0800 Subject: [PATCH 1/2] feat: add --send-time flag to send/reply/reply-all/forward and new +cancel-scheduled-send shortcut Add scheduled send support to the Mail CLI: - New shortcuts/mail/scheduled_send.go with parseAndValidateSendTime (RFC 3339 parsing, min 5-minute lead time, UTC default) and formatScheduledTimeHuman - New shortcuts/mail/scheduled_send_test.go with comprehensive time parsing tests - Modified +send, +reply, +reply-all, +forward shortcuts to accept --send-time flag with validation, scheduled send via SendWithBody, and status/hint output - New +cancel-scheduled-send shortcut with --message-id (required) and --user-mailbox-id (default: me) flags - New shortcuts/mail/mail_cancel_scheduled_send_test.go with success, error, missing-id, and custom-mailbox test cases - Added SendWithBody to draft/service.go for passing send_time in request body - Registered MailCancelScheduledSend in shortcuts.go --- shortcuts/mail/draft/service.go | 9 ++ shortcuts/mail/mail_cancel_scheduled_send.go | 78 ++++++++++ .../mail/mail_cancel_scheduled_send_test.go | 96 ++++++++++++ shortcuts/mail/mail_forward.go | 36 ++++- shortcuts/mail/mail_reply.go | 36 ++++- shortcuts/mail/mail_reply_all.go | 36 ++++- shortcuts/mail/mail_send.go | 36 ++++- shortcuts/mail/scheduled_send.go | 65 +++++++++ shortcuts/mail/scheduled_send_test.go | 137 ++++++++++++++++++ shortcuts/mail/shortcuts.go | 1 + 10 files changed, 518 insertions(+), 12 deletions(-) create mode 100644 shortcuts/mail/mail_cancel_scheduled_send.go create mode 100644 shortcuts/mail/mail_cancel_scheduled_send_test.go create mode 100644 shortcuts/mail/scheduled_send.go create mode 100644 shortcuts/mail/scheduled_send_test.go diff --git a/shortcuts/mail/draft/service.go b/shortcuts/mail/draft/service.go index e62ab680f..91246d6ed 100644 --- a/shortcuts/mail/draft/service.go +++ b/shortcuts/mail/draft/service.go @@ -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) diff --git a/shortcuts/mail/mail_cancel_scheduled_send.go b/shortcuts/mail/mail_cancel_scheduled_send.go new file mode 100644 index 000000000..16be1670a --- /dev/null +++ b/shortcuts/mail/mail_cancel_scheduled_send.go @@ -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.", + ) + } + + 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)) + + return nil + }, +} diff --git a/shortcuts/mail/mail_cancel_scheduled_send_test.go b/shortcuts/mail/mail_cancel_scheduled_send_test.go new file mode 100644 index 000000000..a6e245899 --- /dev/null +++ b/shortcuts/mail/mail_cancel_scheduled_send_test.go @@ -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"]) + } +} diff --git a/shortcuts/mail/mail_forward.go b/shortcuts/mail/mail_forward.go index a270277b5..b973fa3f8 100644 --- a/shortcuts/mail/mail_forward.go +++ b/shortcuts/mail/mail_forward.go @@ -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\":\"\",\"file_path\":\"\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via 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") @@ -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 { @@ -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) @@ -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 }, diff --git a/shortcuts/mail/mail_reply.go b/shortcuts/mail/mail_reply.go index 53370d5f9..eecaa37fb 100644 --- a/shortcuts/mail/mail_reply.go +++ b/shortcuts/mail/mail_reply.go @@ -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\":\"\",\"file_path\":\"\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via 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") @@ -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 { @@ -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 { @@ -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 }, diff --git a/shortcuts/mail/mail_reply_all.go b/shortcuts/mail/mail_reply_all.go index f8d6a4529..4d16dd6b8 100644 --- a/shortcuts/mail/mail_reply_all.go +++ b/shortcuts/mail/mail_reply_all.go @@ -33,6 +33,7 @@ var MailReplyAll = 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\":\"\",\"file_path\":\"\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via 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") @@ -57,6 +58,11 @@ var MailReplyAll = 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 { @@ -70,6 +76,7 @@ var MailReplyAll = 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 { @@ -193,16 +200,39 @@ var MailReplyAll = 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-all (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 }, diff --git a/shortcuts/mail/mail_send.go b/shortcuts/mail/mail_send.go index 6eb11e754..69e7a20d6 100644 --- a/shortcuts/mail/mail_send.go +++ b/shortcuts/mail/mail_send.go @@ -32,6 +32,7 @@ var MailSend = 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\":\"\",\"file_path\":\"\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via 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 email immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."}, + {Name: "send-time", Desc: "Schedule the email 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 { to := runtime.Str("to") @@ -62,6 +63,11 @@ var MailSend = common.Shortcut{ if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); 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"), runtime.Str("body")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -74,6 +80,7 @@ var MailSend = common.Shortcut{ attachFlag := runtime.Str("attach") inlineFlag := runtime.Str("inline") confirmSend := runtime.Bool("confirm-send") + sendTimeStr := runtime.Str("send-time") senderEmail := resolveComposeSenderEmail(runtime) @@ -143,16 +150,39 @@ var MailSend = 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 email (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) return nil }, } diff --git a/shortcuts/mail/scheduled_send.go b/shortcuts/mail/scheduled_send.go new file mode 100644 index 000000000..2ee904d3b --- /dev/null +++ b/shortcuts/mail/scheduled_send.go @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "fmt" + "time" + + "github.com/larksuite/cli/internal/output" +) + +const ( + // minScheduleLeadTime is the minimum time in the future for scheduled send. + minScheduleLeadTime = 5 * time.Minute +) + +// parseAndValidateSendTime parses and validates the --send-time flag value. +// Returns the RFC 3339 string to pass to the API, or an error if invalid. +// If sendTimeStr is empty, returns ("", nil) indicating immediate send. +func parseAndValidateSendTime(sendTimeStr string) (string, error) { + if sendTimeStr == "" { + return "", nil + } + + t, err := time.Parse(time.RFC3339, sendTimeStr) + if err != nil { + // Try parsing without timezone offset — default to UTC + t, err = time.Parse("2006-01-02T15:04:05", sendTimeStr) + if err != nil { + return "", output.ErrValidation( + "Invalid time format for --send-time. Use RFC 3339 format, e.g. 2026-04-14T09:00:00+08:00", + ) + } + t = t.UTC() + } + + if time.Until(t) < minScheduleLeadTime { + return "", output.ErrValidation( + "Scheduled time must be at least 5 minutes in the future", + ) + } + + return t.Format(time.RFC3339), nil +} + +// formatScheduledTimeHuman returns a human-readable scheduled time string +// for pretty output, e.g. "2026-04-14T09:00:00+08:00 (Mon, in 14 hours)" +func formatScheduledTimeHuman(sendTime string) string { + t, err := time.Parse(time.RFC3339, sendTime) + if err != nil { + return sendTime + } + dur := time.Until(t) + var relative string + switch { + case dur < time.Hour: + relative = fmt.Sprintf("in %d minutes", int(dur.Minutes())) + case dur < 24*time.Hour: + relative = fmt.Sprintf("in %d hours", int(dur.Hours())) + default: + relative = fmt.Sprintf("in %d days", int(dur.Hours()/24)) + } + return fmt.Sprintf("%s (%s, %s)", sendTime, t.Format("Mon"), relative) +} diff --git a/shortcuts/mail/scheduled_send_test.go b/shortcuts/mail/scheduled_send_test.go new file mode 100644 index 000000000..2791f8438 --- /dev/null +++ b/shortcuts/mail/scheduled_send_test.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/output" +) + +func TestParseAndValidateSendTime_Empty(t *testing.T) { + result, err := parseAndValidateSendTime("") + if err != nil { + t.Fatalf("expected no error for empty string, got: %v", err) + } + if result != "" { + t.Fatalf("expected empty result for empty string, got: %q", result) + } +} + +func TestParseAndValidateSendTime_ValidRFC3339(t *testing.T) { + future := time.Now().Add(1 * time.Hour).Format(time.RFC3339) + result, err := parseAndValidateSendTime(future) + if err != nil { + t.Fatalf("expected no error for valid RFC 3339 time, got: %v", err) + } + if result == "" { + t.Fatal("expected non-empty result for valid RFC 3339 time") + } +} + +func TestParseAndValidateSendTime_ValidWithTimezone(t *testing.T) { + future := time.Now().Add(2 * time.Hour) + // Use a specific timezone offset + loc := time.FixedZone("CST", 8*60*60) + timeStr := future.In(loc).Format(time.RFC3339) + result, err := parseAndValidateSendTime(timeStr) + if err != nil { + t.Fatalf("expected no error for valid time with timezone, got: %v", err) + } + if result == "" { + t.Fatal("expected non-empty result") + } +} + +func TestParseAndValidateSendTime_WithoutTimezoneDefaultsToUTC(t *testing.T) { + // Use a time far enough in the future to pass the 5-minute check + future := time.Now().UTC().Add(1 * time.Hour) + timeStr := future.Format("2006-01-02T15:04:05") + result, err := parseAndValidateSendTime(timeStr) + if err != nil { + t.Fatalf("expected no error for time without timezone, got: %v", err) + } + if result == "" { + t.Fatal("expected non-empty result") + } + // The result should be valid RFC 3339 + if _, parseErr := time.Parse(time.RFC3339, result); parseErr != nil { + t.Fatalf("result %q is not valid RFC 3339: %v", result, parseErr) + } +} + +func TestParseAndValidateSendTime_InvalidFormat(t *testing.T) { + _, err := parseAndValidateSendTime("not-a-date") + if err == nil { + t.Fatal("expected error for invalid format, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %T: %v", err, err) + } + if !strings.Contains(exitErr.Error(), "Invalid time format") { + t.Errorf("expected 'Invalid time format' in error, got: %v", exitErr) + } +} + +func TestParseAndValidateSendTime_TooSoon(t *testing.T) { + // Time that is only 1 minute in the future — should fail the 5-minute check + soon := time.Now().Add(1 * time.Minute).Format(time.RFC3339) + _, err := parseAndValidateSendTime(soon) + if err == nil { + t.Fatal("expected error for time too soon, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %T: %v", err, err) + } + if !strings.Contains(exitErr.Error(), "at least 5 minutes") { + t.Errorf("expected '5 minutes' in error, got: %v", exitErr) + } +} + +func TestParseAndValidateSendTime_PastTime(t *testing.T) { + past := time.Now().Add(-1 * time.Hour).Format(time.RFC3339) + _, err := parseAndValidateSendTime(past) + if err == nil { + t.Fatal("expected error for past time, got nil") + } +} + +func TestFormatScheduledTimeHuman_ValidTime(t *testing.T) { + future := time.Now().Add(2 * time.Hour).Format(time.RFC3339) + result := formatScheduledTimeHuman(future) + if !strings.Contains(result, "in ") { + t.Errorf("expected relative time description, got: %q", result) + } + if !strings.Contains(result, future) { + t.Errorf("expected original time in output, got: %q", result) + } +} + +func TestFormatScheduledTimeHuman_InvalidTime(t *testing.T) { + result := formatScheduledTimeHuman("not-a-date") + if result != "not-a-date" { + t.Errorf("expected passthrough for invalid time, got: %q", result) + } +} + +func TestFormatScheduledTimeHuman_DaysAway(t *testing.T) { + future := time.Now().Add(72 * time.Hour).Format(time.RFC3339) + result := formatScheduledTimeHuman(future) + if !strings.Contains(result, "days") { + t.Errorf("expected 'days' in output, got: %q", result) + } +} + +func TestFormatScheduledTimeHuman_MinutesAway(t *testing.T) { + future := time.Now().Add(30 * time.Minute).Format(time.RFC3339) + result := formatScheduledTimeHuman(future) + if !strings.Contains(result, "minutes") { + t.Errorf("expected 'minutes' in output, got: %q", result) + } +} diff --git a/shortcuts/mail/shortcuts.go b/shortcuts/mail/shortcuts.go index eb9f2d275..960497c3a 100644 --- a/shortcuts/mail/shortcuts.go +++ b/shortcuts/mail/shortcuts.go @@ -19,5 +19,6 @@ func Shortcuts() []common.Shortcut { MailDraftCreate, MailDraftEdit, MailForward, + MailCancelScheduledSend, } } From 150b713497bcb5cc5ae4f9ff024806356a071fa6 Mon Sep 17 00:00:00 2001 From: "fengzhihao.infeng" Date: Mon, 13 Apr 2026 23:30:26 +0800 Subject: [PATCH 2/2] fix: return Unix timestamp instead of RFC 3339 from parseAndValidateSendTime The backend API expects send_time as a Unix seconds timestamp (i64), not an RFC 3339 string. Update parseAndValidateSendTime to return fmt.Sprintf("%d", t.Unix()) and adjust unit tests accordingly. sprint: S1 --- shortcuts/mail/scheduled_send.go | 7 ++++--- shortcuts/mail/scheduled_send_test.go | 30 +++++++++++++++++++++++---- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/shortcuts/mail/scheduled_send.go b/shortcuts/mail/scheduled_send.go index 2ee904d3b..9352d7695 100644 --- a/shortcuts/mail/scheduled_send.go +++ b/shortcuts/mail/scheduled_send.go @@ -16,8 +16,9 @@ const ( ) // parseAndValidateSendTime parses and validates the --send-time flag value. -// Returns the RFC 3339 string to pass to the API, or an error if invalid. -// If sendTimeStr is empty, returns ("", nil) indicating immediate send. +// Returns a Unix timestamp string (seconds since epoch) to pass to the API, +// or an error if invalid. If sendTimeStr is empty, returns ("", nil) indicating +// immediate send. func parseAndValidateSendTime(sendTimeStr string) (string, error) { if sendTimeStr == "" { return "", nil @@ -41,7 +42,7 @@ func parseAndValidateSendTime(sendTimeStr string) (string, error) { ) } - return t.Format(time.RFC3339), nil + return fmt.Sprintf("%d", t.Unix()), nil } // formatScheduledTimeHuman returns a human-readable scheduled time string diff --git a/shortcuts/mail/scheduled_send_test.go b/shortcuts/mail/scheduled_send_test.go index 2791f8438..d5573aa63 100644 --- a/shortcuts/mail/scheduled_send_test.go +++ b/shortcuts/mail/scheduled_send_test.go @@ -5,6 +5,8 @@ package mail import ( "errors" + "fmt" + "strconv" "strings" "testing" "time" @@ -23,7 +25,8 @@ func TestParseAndValidateSendTime_Empty(t *testing.T) { } func TestParseAndValidateSendTime_ValidRFC3339(t *testing.T) { - future := time.Now().Add(1 * time.Hour).Format(time.RFC3339) + futureTime := time.Now().Add(1 * time.Hour) + future := futureTime.Format(time.RFC3339) result, err := parseAndValidateSendTime(future) if err != nil { t.Fatalf("expected no error for valid RFC 3339 time, got: %v", err) @@ -31,6 +34,15 @@ func TestParseAndValidateSendTime_ValidRFC3339(t *testing.T) { if result == "" { t.Fatal("expected non-empty result for valid RFC 3339 time") } + // Result should be a Unix timestamp string + ts, parseErr := strconv.ParseInt(result, 10, 64) + if parseErr != nil { + t.Fatalf("expected Unix timestamp string, got %q: %v", result, parseErr) + } + expected := futureTime.Unix() + if ts != expected { + t.Fatalf("expected Unix timestamp %d, got %d", expected, ts) + } } func TestParseAndValidateSendTime_ValidWithTimezone(t *testing.T) { @@ -45,6 +57,11 @@ func TestParseAndValidateSendTime_ValidWithTimezone(t *testing.T) { if result == "" { t.Fatal("expected non-empty result") } + // Result should be a Unix timestamp string + expected := fmt.Sprintf("%d", future.Unix()) + if result != expected { + t.Fatalf("expected Unix timestamp %s, got %s", expected, result) + } } func TestParseAndValidateSendTime_WithoutTimezoneDefaultsToUTC(t *testing.T) { @@ -58,9 +75,14 @@ func TestParseAndValidateSendTime_WithoutTimezoneDefaultsToUTC(t *testing.T) { if result == "" { t.Fatal("expected non-empty result") } - // The result should be valid RFC 3339 - if _, parseErr := time.Parse(time.RFC3339, result); parseErr != nil { - t.Fatalf("result %q is not valid RFC 3339: %v", result, parseErr) + // The result should be a Unix timestamp string + ts, parseErr := strconv.ParseInt(result, 10, 64) + if parseErr != nil { + t.Fatalf("expected Unix timestamp string, got %q: %v", result, parseErr) + } + expected := future.Unix() + if ts != expected { + t.Fatalf("expected Unix timestamp %d, got %d", expected, ts) } }