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..9352d7695 --- /dev/null +++ b/shortcuts/mail/scheduled_send.go @@ -0,0 +1,66 @@ +// 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 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 + } + + 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 fmt.Sprintf("%d", t.Unix()), 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..d5573aa63 --- /dev/null +++ b/shortcuts/mail/scheduled_send_test.go @@ -0,0 +1,159 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "errors" + "fmt" + "strconv" + "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) { + 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) + } + 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) { + 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") + } + // 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) { + // 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 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) + } +} + +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, } }