diff --git a/shortcuts/mail/draft/service.go b/shortcuts/mail/draft/service.go index e62ab680f..1eaf2908b 100644 --- a/shortcuts/mail/draft/service.go +++ b/shortcuts/mail/draft/service.go @@ -60,7 +60,21 @@ func UpdateWithRaw(runtime *common.RuntimeContext, mailboxID, draftID, rawEML st } func Send(runtime *common.RuntimeContext, mailboxID, draftID string) (map[string]interface{}, error) { - return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, nil) + return SendWithTime(runtime, mailboxID, draftID, "") +} + +// SendWithTime sends a draft immediately or schedules it if sendTime is set. +func SendWithTime(runtime *common.RuntimeContext, mailboxID, draftID, sendTime string) (map[string]interface{}, error) { + var body map[string]interface{} + if strings.TrimSpace(sendTime) != "" { + body = map[string]interface{}{"send_time": sendTime} + } + return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, body) +} + +// CancelScheduledSend cancels a scheduled send for a message and restores the draft. +func CancelScheduledSend(runtime *common.RuntimeContext, mailboxID, messageID string) (map[string]interface{}, error) { + return runtime.CallAPI("POST", mailboxPath(mailboxID, "messages", messageID, "cancel_scheduled_send"), nil, nil) } func extractDraftID(data map[string]interface{}) string { diff --git a/shortcuts/mail/helpers.go b/shortcuts/mail/helpers.go index 0b1958d31..88285cd22 100644 --- a/shortcuts/mail/helpers.go +++ b/shortcuts/mail/helpers.go @@ -1935,11 +1935,15 @@ func validateConfirmSendScope(runtime *common.RuntimeContext) error { // buildSendResult builds the output map for a successful send, including // recall tip if the backend indicates the message is recallable. -func buildSendResult(resData map[string]interface{}, mailboxID string) map[string]interface{} { +func buildSendResult(resData map[string]interface{}, mailboxID string, sendTime ...string) map[string]interface{} { result := map[string]interface{}{ "message_id": resData["message_id"], "thread_id": resData["thread_id"], } + if len(sendTime) > 0 && strings.TrimSpace(sendTime[0]) != "" { + result["scheduled_send_time"] = sendTime[0] + result["scheduled_send_time_human"] = formatScheduledTimeHuman(sendTime[0]) + } if recallStatus, ok := resData["recall_status"].(string); ok && recallStatus == "available" { messageID, _ := resData["message_id"].(string) result["recall_available"] = true diff --git a/shortcuts/mail/mail_cancel_scheduled_send.go b/shortcuts/mail/mail_cancel_scheduled_send.go new file mode 100644 index 000000000..417cbcfbe --- /dev/null +++ b/shortcuts/mail/mail_cancel_scheduled_send.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" + draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" +) + +var MailCancelScheduledSend = common.Shortcut{ + Service: "mail", + Command: "+cancel-scheduled-send", + Description: "Cancel a scheduled email send and restore the message as a draft.", + Risk: "write", + Scopes: []string{"mail:user_mailbox.message:send"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "message-id", Desc: "Required. Message ID of the scheduled send to cancel", Required: true}, + {Name: "mailbox", Desc: "Mailbox email address that owns the scheduled message (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 { + mailboxID := resolveComposeMailboxID(runtime) + messageID := runtime.Str("message-id") + return common.NewDryRunAPI(). + Desc("Cancel a scheduled send and restore the draft"). + POST(mailboxPath(mailboxID, "messages", messageID, "cancel_scheduled_send")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + mailboxID := resolveComposeMailboxID(runtime) + messageID := runtime.Str("message-id") + + resp, err := draftpkg.CancelScheduledSend(runtime, mailboxID, messageID) + if err != nil { + return output.ErrWithHint(output.ExitAPI, "api_error", fmt.Sprintf("Failed to cancel scheduled send: %v", err), "Check the message ID and make sure the message is still scheduled for delivery.") + } + + out := map[string]interface{}{ + "message_id": messageID, + "mailbox_id": mailboxID, + } + for k, v := range resp { + out[k] = v + } + out["status"] = "canceled" + runtime.Out(out, nil) + return nil + }, +} diff --git a/shortcuts/mail/mail_forward.go b/shortcuts/mail/mail_forward.go index 7337dcc1d..a2d8592a4 100644 --- a/shortcuts/mail/mail_forward.go +++ b/shortcuts/mail/mail_forward.go @@ -18,7 +18,7 @@ import ( var MailForward = common.Shortcut{ Service: "mail", Command: "+forward", - Description: "Forward a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Original message block included automatically.", + Description: "Forward a message and save as draft (default). Use --confirm-send to send immediately after user confirmation, or pair --confirm-send with --send-time to schedule a future send. Original message block included automatically.", Risk: "write", Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, AuthTypes: []string{"user"}, @@ -33,12 +33,14 @@ var MailForward = common.Shortcut{ {Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring all HTML auto-detection. Cannot be used with --inline."}, {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: "send-time", Desc: "Scheduled send time in RFC3339 format. Use with --confirm-send to schedule a future send. If the timezone is omitted, UTC is assumed; the time must be at least 5 minutes in the future."}, {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."}, signatureFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { messageId := runtime.Str("message-id") to := runtime.Str("to") confirmSend := runtime.Bool("confirm-send") + sendTime := runtime.Str("send-time") mailboxID := resolveComposeMailboxID(runtime) desc := "Forward: fetch original message → resolve sender address → save as draft" if confirmSend { @@ -52,6 +54,9 @@ var MailForward = common.Shortcut{ Body(map[string]interface{}{"raw": "", "_to": to}) if confirmSend { api = api.POST(mailboxPath(mailboxID, "drafts", "", "send")) + if sendTime != "" { + api = api.Body(map[string]interface{}{"send_time": sendTime}) + } } return api }, @@ -59,6 +64,11 @@ var MailForward = 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 + } + } if runtime.Bool("confirm-send") { if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil { return err @@ -79,6 +89,7 @@ var MailForward = common.Shortcut{ attachFlag := runtime.Str("attach") inlineFlag := runtime.Str("inline") confirmSend := runtime.Bool("confirm-send") + sendTime := runtime.Str("send-time") signatureID := runtime.Str("signature-id") mailboxID := resolveComposeMailboxID(runtime) @@ -224,18 +235,29 @@ var MailForward = common.Shortcut{ return fmt.Errorf("failed to create draft: %w", err) } if !confirmSend { + tip := fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftID) + if strings.TrimSpace(sendTime) != "" { + tip = fmt.Sprintf("draft saved. To schedule send, rerun with --confirm-send and --send-time %q", sendTime) + } runtime.Out(map[string]interface{}{ "draft_id": draftID, - "tip": fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftID), + "tip": tip, }, nil) hintSendDraft(runtime, mailboxID, draftID) return nil } - resData, err := draftpkg.Send(runtime, mailboxID, draftID) + var validatedSendTime string + if strings.TrimSpace(sendTime) != "" { + validatedSendTime, err = parseAndValidateSendTime(sendTime) + if err != nil { + return err + } + } + resData, err := draftpkg.SendWithTime(runtime, mailboxID, draftID, validatedSendTime) if err != nil { return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftID, err) } - runtime.Out(buildSendResult(resData, mailboxID), nil) + runtime.Out(buildSendResult(resData, mailboxID, validatedSendTime), nil) hintMarkAsRead(runtime, mailboxID, messageId) return nil }, diff --git a/shortcuts/mail/mail_reply.go b/shortcuts/mail/mail_reply.go index 52e901087..f257ae09d 100644 --- a/shortcuts/mail/mail_reply.go +++ b/shortcuts/mail/mail_reply.go @@ -16,7 +16,7 @@ import ( var MailReply = common.Shortcut{ Service: "mail", Command: "+reply", - Description: "Reply to a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Sets Re: subject, In-Reply-To, and References headers automatically.", + Description: "Reply to a message and save as draft (default). Use --confirm-send to send immediately after user confirmation, or pair --confirm-send with --send-time to schedule a future send. Sets Re: subject, In-Reply-To, and References headers automatically.", Risk: "write", Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, AuthTypes: []string{"user"}, @@ -31,11 +31,13 @@ var MailReply = common.Shortcut{ {Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring all HTML auto-detection. Cannot be used with --inline."}, {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: "send-time", Desc: "Scheduled send time in RFC3339 format. Use with --confirm-send to schedule a future send. If the timezone is omitted, UTC is assumed; the time must be at least 5 minutes in the future."}, {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."}, signatureFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { messageId := runtime.Str("message-id") confirmSend := runtime.Bool("confirm-send") + sendTime := runtime.Str("send-time") mailboxID := resolveComposeMailboxID(runtime) desc := "Reply: fetch original message → resolve sender address → save as draft" if confirmSend { @@ -49,6 +51,9 @@ var MailReply = common.Shortcut{ Body(map[string]interface{}{"raw": ""}) if confirmSend { api = api.POST(mailboxPath(mailboxID, "drafts", "", "send")) + if sendTime != "" { + api = api.Body(map[string]interface{}{"send_time": sendTime}) + } } return api }, @@ -56,6 +61,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 + } + } if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil { return err } @@ -71,6 +81,7 @@ var MailReply = common.Shortcut{ attachFlag := runtime.Str("attach") inlineFlag := runtime.Str("inline") confirmSend := runtime.Bool("confirm-send") + sendTime := runtime.Str("send-time") inlineSpecs, err := parseInlineSpecs(inlineFlag) if err != nil { @@ -187,18 +198,29 @@ var MailReply = common.Shortcut{ return fmt.Errorf("failed to create draft: %w", err) } if !confirmSend { + tip := fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftID) + if strings.TrimSpace(sendTime) != "" { + tip = fmt.Sprintf("draft saved. To schedule send, rerun with --confirm-send and --send-time %q", sendTime) + } runtime.Out(map[string]interface{}{ "draft_id": draftID, - "tip": fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftID), + "tip": tip, }, nil) hintSendDraft(runtime, mailboxID, draftID) return nil } - resData, err := draftpkg.Send(runtime, mailboxID, draftID) + var validatedSendTime string + if strings.TrimSpace(sendTime) != "" { + validatedSendTime, err = parseAndValidateSendTime(sendTime) + if err != nil { + return err + } + } + resData, err := draftpkg.SendWithTime(runtime, mailboxID, draftID, validatedSendTime) if err != nil { return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftID, err) } - runtime.Out(buildSendResult(resData, mailboxID), nil) + runtime.Out(buildSendResult(resData, mailboxID, validatedSendTime), nil) hintMarkAsRead(runtime, mailboxID, messageId) return nil }, diff --git a/shortcuts/mail/mail_reply_all.go b/shortcuts/mail/mail_reply_all.go index ab8b44298..4d5dc8068 100644 --- a/shortcuts/mail/mail_reply_all.go +++ b/shortcuts/mail/mail_reply_all.go @@ -16,7 +16,7 @@ import ( var MailReplyAll = common.Shortcut{ Service: "mail", Command: "+reply-all", - Description: "Reply to all recipients and save as draft (default). Use --confirm-send to send immediately after user confirmation. Includes all original To and CC automatically.", + Description: "Reply to all recipients and save as draft (default). Use --confirm-send to send immediately after user confirmation, or pair --confirm-send with --send-time to schedule a future send. Includes all original To and CC automatically.", Risk: "write", Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, AuthTypes: []string{"user"}, @@ -32,11 +32,13 @@ var MailReplyAll = common.Shortcut{ {Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring all HTML auto-detection. Cannot be used with --inline."}, {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: "send-time", Desc: "Scheduled send time in RFC3339 format. Use with --confirm-send to schedule a future send. If the timezone is omitted, UTC is assumed; the time must be at least 5 minutes in the future."}, {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."}, signatureFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { messageId := runtime.Str("message-id") confirmSend := runtime.Bool("confirm-send") + sendTime := runtime.Str("send-time") mailboxID := resolveComposeMailboxID(runtime) desc := "Reply-all: fetch original message (with recipients) → resolve sender address → save as draft" if confirmSend { @@ -50,6 +52,9 @@ var MailReplyAll = common.Shortcut{ Body(map[string]interface{}{"raw": ""}) if confirmSend { api = api.POST(mailboxPath(mailboxID, "drafts", "", "send")) + if sendTime != "" { + api = api.Body(map[string]interface{}{"send_time": sendTime}) + } } return api }, @@ -57,6 +62,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 + } + } if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil { return err } @@ -73,6 +83,7 @@ var MailReplyAll = common.Shortcut{ attachFlag := runtime.Str("attach") inlineFlag := runtime.Str("inline") confirmSend := runtime.Bool("confirm-send") + sendTime := runtime.Str("send-time") inlineSpecs, err := parseInlineSpecs(inlineFlag) if err != nil { @@ -201,18 +212,29 @@ var MailReplyAll = common.Shortcut{ return fmt.Errorf("failed to create draft: %w", err) } if !confirmSend { + tip := fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftID) + if strings.TrimSpace(sendTime) != "" { + tip = fmt.Sprintf("draft saved. To schedule send, rerun with --confirm-send and --send-time %q", sendTime) + } runtime.Out(map[string]interface{}{ "draft_id": draftID, - "tip": fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftID), + "tip": tip, }, nil) hintSendDraft(runtime, mailboxID, draftID) return nil } - resData, err := draftpkg.Send(runtime, mailboxID, draftID) + var validatedSendTime string + if strings.TrimSpace(sendTime) != "" { + validatedSendTime, err = parseAndValidateSendTime(sendTime) + if err != nil { + return err + } + } + resData, err := draftpkg.SendWithTime(runtime, mailboxID, draftID, validatedSendTime) if err != nil { return fmt.Errorf("failed to send reply-all (draft %s created but not sent): %w", draftID, err) } - runtime.Out(buildSendResult(resData, mailboxID), nil) + runtime.Out(buildSendResult(resData, mailboxID, validatedSendTime), nil) hintMarkAsRead(runtime, mailboxID, messageId) return nil }, diff --git a/shortcuts/mail/mail_scheduled_send_test.go b/shortcuts/mail/mail_scheduled_send_test.go new file mode 100644 index 000000000..190b67257 --- /dev/null +++ b/shortcuts/mail/mail_scheduled_send_test.go @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "encoding/json" + "testing" + "time" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestMailSendConfirmSendWiresScheduledSendTime(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + + scheduled := time.Now().Add(10 * time.Minute).UTC().Format(time.RFC3339) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/user_mailboxes/me/drafts", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "draft_id": "draft_123", + }, + }, + }) + sendStub := &httpmock.Stub{ + Method: "POST", + URL: "/user_mailboxes/me/drafts/draft_123/send", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "message_id": "msg_123", + "thread_id": "thread_123", + }, + }, + } + reg.Register(sendStub) + + err := runMountedMailShortcut(t, MailSend, []string{ + "+send", + "--mailbox", "me", + "--from", "alias@example.com", + "--to", "alice@example.com", + "--subject", "Scheduled", + "--body", "hello", + "--confirm-send", + "--send-time", scheduled, + }, f, stdout) + if err != nil { + t.Fatalf("MailSend execution failed: %v", err) + } + + var sendBody map[string]interface{} + if err := json.Unmarshal(sendStub.CapturedBody, &sendBody); err != nil { + t.Fatalf("unmarshal send body: %v", err) + } + if got, ok := sendBody["send_time"].(string); !ok || got != scheduled { + t.Fatalf("send_time = %v, want %q", sendBody["send_time"], scheduled) + } + + data := decodeShortcutEnvelopeData(t, stdout) + if got := data["message_id"]; got != "msg_123" { + t.Fatalf("message_id = %v, want msg_123", got) + } + if got := data["scheduled_send_time"]; got != scheduled { + t.Fatalf("scheduled_send_time = %v, want %q", got, scheduled) + } + if got := data["scheduled_send_time_human"]; got == "" { + t.Fatal("expected scheduled_send_time_human in output") + } +} + +func TestMailCancelScheduledSendExecute(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/user_mailboxes/me/messages/msg_123/cancel_scheduled_send", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "message_id": "msg_123", + "draft_id": "draft_123", + }, + }, + }) + + err := runMountedMailShortcut(t, MailCancelScheduledSend, []string{ + "+cancel-scheduled-send", + "--mailbox", "me", + "--message-id", "msg_123", + }, f, stdout) + if err != nil { + t.Fatalf("MailCancelScheduledSend execution failed: %v", err) + } + + data := decodeShortcutEnvelopeData(t, stdout) + if got := data["message_id"]; got != "msg_123" { + t.Fatalf("message_id = %v, want msg_123", got) + } + if got := data["draft_id"]; got != "draft_123" { + t.Fatalf("draft_id = %v, want draft_123", got) + } + if got := data["mailbox_id"]; got != "me" { + t.Fatalf("mailbox_id = %v, want me", got) + } + if got := data["status"]; got != "canceled" { + t.Fatalf("status = %v, want canceled", got) + } +} + +func TestMailShortcutsIncludesCancelScheduledSend(t *testing.T) { + for _, s := range Shortcuts() { + if s.Command == "+cancel-scheduled-send" { + return + } + } + t.Fatal("Shortcuts() missing +cancel-scheduled-send") +} diff --git a/shortcuts/mail/mail_send.go b/shortcuts/mail/mail_send.go index 1231f2f37..2b2f56e07 100644 --- a/shortcuts/mail/mail_send.go +++ b/shortcuts/mail/mail_send.go @@ -16,7 +16,7 @@ import ( var MailSend = common.Shortcut{ Service: "mail", Command: "+send", - Description: "Compose a new email and save as draft (default). Use --confirm-send to send immediately after user confirmation.", + Description: "Compose a new email and save as draft (default). Use --confirm-send to send immediately after user confirmation, or pair --confirm-send with --send-time to schedule a future send.", Risk: "write", Scopes: []string{"mail:user_mailbox.message:send", "mail:user_mailbox.message:modify", "mail:user_mailbox:readonly"}, AuthTypes: []string{"user"}, @@ -31,12 +31,14 @@ var MailSend = common.Shortcut{ {Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring HTML auto-detection. Cannot be used with --inline."}, {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: "send-time", Desc: "Scheduled send time in RFC3339 format. Use with --confirm-send to schedule a future send. If the timezone is omitted, UTC is assumed; the time must be at least 5 minutes in the future."}, {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."}, signatureFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { to := runtime.Str("to") subject := runtime.Str("subject") confirmSend := runtime.Bool("confirm-send") + sendTime := runtime.Str("send-time") mailboxID := resolveComposeMailboxID(runtime) desc := "Compose email → save as draft" if confirmSend { @@ -55,6 +57,9 @@ var MailSend = common.Shortcut{ }) if confirmSend { api = api.POST(mailboxPath(mailboxID, "drafts", "", "send")) + if sendTime != "" { + api = api.Body(map[string]interface{}{"send_time": sendTime}) + } } return api }, @@ -62,6 +67,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 + } + } if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil { return err } @@ -77,6 +87,7 @@ var MailSend = common.Shortcut{ attachFlag := runtime.Str("attach") inlineFlag := runtime.Str("inline") confirmSend := runtime.Bool("confirm-send") + sendTime := runtime.Str("send-time") senderEmail := resolveComposeSenderEmail(runtime) signatureID := runtime.Str("signature-id") @@ -155,18 +166,29 @@ var MailSend = common.Shortcut{ return fmt.Errorf("failed to create draft: %w", err) } if !confirmSend { + tip := fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftID) + if strings.TrimSpace(sendTime) != "" { + tip = fmt.Sprintf("draft saved. To schedule send, rerun with --confirm-send and --send-time %q", sendTime) + } runtime.Out(map[string]interface{}{ "draft_id": draftID, - "tip": fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftID), + "tip": tip, }, nil) hintSendDraft(runtime, mailboxID, draftID) return nil } - resData, err := draftpkg.Send(runtime, mailboxID, draftID) + var validatedSendTime string + if strings.TrimSpace(sendTime) != "" { + validatedSendTime, err = parseAndValidateSendTime(sendTime) + if err != nil { + return err + } + } + resData, err := draftpkg.SendWithTime(runtime, mailboxID, draftID, validatedSendTime) if err != nil { return fmt.Errorf("failed to send email (draft %s created but not sent): %w", draftID, err) } - runtime.Out(buildSendResult(resData, mailboxID), nil) + runtime.Out(buildSendResult(resData, mailboxID, validatedSendTime), nil) return nil }, } diff --git a/shortcuts/mail/send_time.go b/shortcuts/mail/send_time.go new file mode 100644 index 000000000..5ecebf879 --- /dev/null +++ b/shortcuts/mail/send_time.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "fmt" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" +) + +const minScheduledSendLeadTime = 5 * time.Minute + +// parseAndValidateSendTime validates --send-time and returns a normalized RFC3339 +// string. If the input omits a timezone offset, UTC is assumed. +func parseAndValidateSendTime(sendTimeStr string) (string, error) { + sendTimeStr = strings.TrimSpace(sendTimeStr) + if sendTimeStr == "" { + return "", nil + } + + t, err := time.Parse(time.RFC3339, sendTimeStr) + if err != nil { + t, err = time.ParseInLocation("2006-01-02T15:04:05", sendTimeStr, time.UTC) + if err != nil { + return "", output.ErrValidation( + "--send-time must be RFC3339, for example 2026-04-14T09:00:00+08:00; if you omit the timezone, UTC is assumed", + ) + } + } + + if time.Until(t) < minScheduledSendLeadTime { + return "", output.ErrValidation("--send-time must be at least 5 minutes in the future") + } + + return t.UTC().Format(time.RFC3339), nil +} + +// formatScheduledTimeHuman renders a scheduled send time with a short relative hint. +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/send_time_test.go b/shortcuts/mail/send_time_test.go new file mode 100644 index 000000000..470da706f --- /dev/null +++ b/shortcuts/mail/send_time_test.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "strings" + "testing" + "time" +) + +func TestParseAndValidateSendTime(t *testing.T) { + now := time.Now() + + t.Run("rfc3339", func(t *testing.T) { + input := now.Add(10 * time.Minute).UTC().Format(time.RFC3339) + got, err := parseAndValidateSendTime(input) + if err != nil { + t.Fatalf("parseAndValidateSendTime() error = %v", err) + } + if got != input { + t.Fatalf("parseAndValidateSendTime() = %q, want %q", got, input) + } + }) + + t.Run("no timezone defaults to utc", func(t *testing.T) { + target := now.Add(10 * time.Minute).UTC() + input := target.Format("2006-01-02T15:04:05") + want := target.Format(time.RFC3339) + got, err := parseAndValidateSendTime(input) + if err != nil { + t.Fatalf("parseAndValidateSendTime() error = %v", err) + } + if got != want { + t.Fatalf("parseAndValidateSendTime() = %q, want %q", got, want) + } + }) + + t.Run("too soon", func(t *testing.T) { + input := now.Add(4 * time.Minute).UTC().Format(time.RFC3339) + _, err := parseAndValidateSendTime(input) + if err == nil { + t.Fatal("parseAndValidateSendTime() expected error for too-soon time") + } + if !strings.Contains(err.Error(), "5 minutes") { + t.Fatalf("expected 5-minute guard error, got %v", err) + } + }) + + t.Run("invalid format", func(t *testing.T) { + _, err := parseAndValidateSendTime("not-a-time") + if err == nil { + t.Fatal("parseAndValidateSendTime() expected error for invalid input") + } + if !strings.Contains(err.Error(), "RFC3339") { + t.Fatalf("expected RFC3339 hint, got %v", err) + } + }) +} diff --git a/shortcuts/mail/shortcuts.go b/shortcuts/mail/shortcuts.go index ef05b37a7..ad387af43 100644 --- a/shortcuts/mail/shortcuts.go +++ b/shortcuts/mail/shortcuts.go @@ -16,6 +16,7 @@ func Shortcuts() []common.Shortcut { MailReply, MailReplyAll, MailSend, + MailCancelScheduledSend, MailDraftCreate, MailDraftEdit, MailForward, diff --git a/skills/lark-mail/SKILL.md b/skills/lark-mail/SKILL.md index 2d047f201..4f21bb3e7 100644 --- a/skills/lark-mail/SKILL.md +++ b/skills/lark-mail/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-mail version: 1.0.0 -description: "飞书邮箱 — draft, compose, send, reply, forward, read, and search emails; manage drafts, folders, labels, contacts, attachments, and mail rules. Use when user mentions 起草邮件, 写一封邮件, 拟邮件, 草稿, 发通知邮件, 发送邮件, 发邮件, 回复邮件, 转发邮件, 查看邮件, 看邮件, 读邮件, 搜索邮件, 查邮件, 收件箱, 邮件会话, 编辑草稿, 管理草稿, 下载附件, 邮件文件夹, 邮件标签, 邮件联系人, 监听新邮件, 收信规则, 邮件规则, draft, compose, send email, reply, forward, inbox, mail thread, mail rules." +description: "飞书邮箱 — draft, compose, send, schedule, cancel scheduled sends, reply, forward, read, and search emails; manage drafts, folders, labels, contacts, attachments, and mail rules. Use when user mentions 起草邮件, 写一封邮件, 拟邮件, 草稿, 定时发送, 取消定时发送, 发通知邮件, 发送邮件, 发邮件, 回复邮件, 转发邮件, 查看邮件, 看邮件, 读邮件, 搜索邮件, 查邮件, 收件箱, 邮件会话, 编辑草稿, 管理草稿, 下载附件, 邮件文件夹, 邮件标签, 邮件联系人, 监听新邮件, 收信规则, 邮件规则, draft, compose, send email, reply, forward, inbox, mail thread, mail rules." metadata: requires: bins: ["lark-cli"] @@ -53,11 +53,12 @@ metadata: 1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。 2. **浏览** — `+triage` 查看收件箱摘要,获取 `message_id` / `thread_id` 3. **阅读** — `+message` 读单封邮件,`+thread` 读整个会话 -4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送) -5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送) -6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送 +4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送;加 `--send-time` 可定时发送) +5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送;加 `--send-time` 可定时发送) +6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送;加 `--send-time` 可定时发送 7. **确认投递** — 发送后用 `send_status` 查询投递状态,向用户报告结果 -8. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op +8. **取消定时发送** — `+cancel-scheduled-send --message-id ` 取消已安排的定时邮件 +9. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op ### CRITICAL — 首次使用任何命令前先查 `-h` @@ -120,12 +121,13 @@ lark-cli mail multi_entity search --as user --data '{"query":"<关键词>"}' | 邮件类型 | 存草稿(不发送) | 直接发送 | |----------|-----------------|---------| -| **新邮件** | `+send` 或 `+draft-create` | `+send --confirm-send` | -| **回复** | `+reply` 或 `+reply-all` | `+reply --confirm-send` 或 `+reply-all --confirm-send` | -| **转发** | `+forward` | `+forward --confirm-send` | +| **新邮件** | `+send` 或 `+draft-create` | `+send --confirm-send` 或 `+send --confirm-send --send-time ` | +| **回复** | `+reply` 或 `+reply-all` | `+reply --confirm-send` / `+reply-all --confirm-send`,或搭配 `--send-time` 定时发送 | +| **转发** | `+forward` | `+forward --confirm-send`,或搭配 `--send-time` 定时发送 | - 有原邮件上下文 → 用 `+reply` / `+reply-all` / `+forward`(默认即草稿),**不要用 `+draft-create`** - **发送前必须向用户确认收件人和内容,用户明确同意后才可加 `--confirm-send`** +- `--send-time` 使用 RFC 3339;如果未提供时区,按 UTC 处理 - **发送后必须调用 `send_status` 确认投递状态**(详见下方说明) ### 使用公共邮箱或别名(send_as)发信 @@ -310,12 +312,13 @@ Shortcut 是对常用操作的高级封装(`lark-cli mail + [flags]`) | [`+thread`](references/lark-mail-thread.md) | Use when querying a full mail conversation/thread by thread ID. Returns all messages in chronological order, including replies and drafts, with body content and attachments metadata, including inline images. | | [`+triage`](references/lark-mail-triage.md) | List mail summaries (date/from/subject/message_id). Use --query for full-text search, --filter for exact-match conditions. | | [`+watch`](references/lark-mail-watch.md) | Watch for incoming mail events via WebSocket (requires scope mail:event and bot event mail.user_mailbox.event.message_received_v1 added). Run with --print-output-schema to see per-format field reference before parsing output. | -| [`+reply`](references/lark-mail-reply.md) | Reply to a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Sets Re: subject, In-Reply-To, and References headers automatically. | -| [`+reply-all`](references/lark-mail-reply-all.md) | Reply to all recipients and save as draft (default). Use --confirm-send to send immediately after user confirmation. Includes all original To and CC automatically. | -| [`+send`](references/lark-mail-send.md) | Compose a new email and save as draft (default). Use --confirm-send to send immediately after user confirmation. | +| [`+reply`](references/lark-mail-reply.md) | Reply to a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Supports `--send-time` for scheduled send. Sets Re: subject, In-Reply-To, and References headers automatically. | +| [`+reply-all`](references/lark-mail-reply-all.md) | Reply to all recipients and save as draft (default). Use --confirm-send to send immediately after user confirmation. Supports `--send-time` for scheduled send. Includes all original To and CC automatically. | +| [`+send`](references/lark-mail-send.md) | Compose a new email and save as draft (default). Use --confirm-send to send immediately after user confirmation. Supports `--send-time` for scheduled send. | | [`+draft-create`](references/lark-mail-draft-create.md) | Create a brand-new mail draft from scratch (NOT for reply or forward). For reply drafts use +reply; for forward drafts use +forward. Only use +draft-create when composing a new email with no parent message. | | [`+draft-edit`](references/lark-mail-draft-edit.md) | Use when updating an existing mail draft without sending it. Prefer this shortcut over calling raw drafts.get or drafts.update directly, because it performs draft-safe MIME read/patch/write editing while preserving unchanged structure, attachments, and headers where possible. | -| [`+forward`](references/lark-mail-forward.md) | Forward a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Original message block included automatically. | +| [`+forward`](references/lark-mail-forward.md) | Forward a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Supports `--send-time` for scheduled send. Original message block included automatically. | +| [`+cancel-scheduled-send`](references/lark-mail-cancel-scheduled-send.md) | Cancel a scheduled email send and restore the message as a draft. | | [`+signature`](references/lark-mail-signature.md) | List or view email signatures with default usage info. | ## API Resources @@ -467,4 +470,3 @@ lark-cli mail [flags] # 调用 API | `user_mailbox.threads.trash` | `mail:user_mailbox.message:modify` | | `user_mailbox.sent_messages.recall` | `mail:user_mailbox.message:modify` | | `user_mailbox.sent_messages.get_recall_detail` | `mail:user_mailbox.message:readonly` | - diff --git a/skills/lark-mail/references/lark-mail-cancel-scheduled-send.md b/skills/lark-mail/references/lark-mail-cancel-scheduled-send.md new file mode 100644 index 000000000..488564ff9 --- /dev/null +++ b/skills/lark-mail/references/lark-mail-cancel-scheduled-send.md @@ -0,0 +1,40 @@ +# mail +cancel-scheduled-send + +> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +取消一封已经安排好的定时发送邮件,并把它恢复为草稿。 + +本 skill 对应 shortcut:`lark-cli mail +cancel-scheduled-send`。 + +## 命令 + +```bash +lark-cli mail +cancel-scheduled-send --message-id <消息ID> +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--message-id ` | 是 | 需要取消定时发送的消息 ID | +| `--mailbox ` | 否 | 目标邮箱,默认 `me` | + +## 返回值 + +```json +{ + "ok": true, + "data": { + "message_id": "msg_123", + "draft_id": "draft_123", + "status": "canceled" + } +} +``` + +## 相关命令 + +- `lark-cli mail +send` — 新建邮件并支持定时发送 +- `lark-cli mail +reply` — 回复邮件并支持定时发送 +- `lark-cli mail +reply-all` — 回复全部并支持定时发送 +- `lark-cli mail +forward` — 转发邮件并支持定时发送 diff --git a/skills/lark-mail/references/lark-mail-forward.md b/skills/lark-mail/references/lark-mail-forward.md index ff145da9f..c9512563f 100644 --- a/skills/lark-mail/references/lark-mail-forward.md +++ b/skills/lark-mail/references/lark-mail-forward.md @@ -21,6 +21,8 @@ lark-cli mail +forward --message-id <邮件ID> --to <收件人> ``` → 返回 `draft_id` +如果需要定时发送,请在 Step 1 使用 `--confirm-send --send-time `。 + **Step 2** — 向用户展示转发摘要(被转发邮件、收件人、附加说明),请求确认发送 **Step 3** — 用户明确同意后,发送该草稿: @@ -48,6 +50,10 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com # 确认发送(用户明确确认后才可使用) lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --confirm-send +# 定时发送(用户明确确认后才可使用) +lark-cli mail +forward --message-id <邮件ID> --to alice@example.com \ + --confirm-send --send-time '2026-04-14T09:00:00+08:00' + # Dry Run(仅打印请求,不发送) lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run ``` @@ -66,6 +72,7 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run | `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 | | `--attach ` | 否 | 附件文件路径,多个用逗号分隔,追加在原邮件附件之后。相对路径 | | `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | +| `--send-time ` | 否 | 定时发送时间。仅在 `--confirm-send` 下生效;如果未提供时区,默认按 UTC 处理。必须至少比当前时间晚 5 分钟 | | `--signature-id ` | 否 | 签名 ID。附加邮箱签名到转发正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 | | `--confirm-send` | 否 | 确认发送转发(默认只保存草稿)。仅在用户明确确认后使用 | | `--dry-run` | 否 | 仅打印请求,不执行 | diff --git a/skills/lark-mail/references/lark-mail-reply-all.md b/skills/lark-mail/references/lark-mail-reply-all.md index d22f1a12a..605e227b5 100644 --- a/skills/lark-mail/references/lark-mail-reply-all.md +++ b/skills/lark-mail/references/lark-mail-reply-all.md @@ -21,6 +21,8 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '<回复正文>' ``` → 返回 `draft_id` +如果需要定时发送,请在 Step 1 使用 `--confirm-send --send-time `。 + **Step 2** — 向用户展示回复摘要(目标邮件、回复内容、完整收件人列表 To/Cc),请求确认发送 **Step 3** — 用户明确同意后,发送该草稿: @@ -51,6 +53,10 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '收到,已处理。' # 确认发送(用户明确确认后才可使用) lark-cli mail +reply-all --message-id <邮件ID> --body '

收到,已处理。

' --confirm-send +# 定时发送(用户明确确认后才可使用) +lark-cli mail +reply-all --message-id <邮件ID> --body '

收到,已处理。

' \ + --confirm-send --send-time '2026-04-14T09:00:00+08:00' + # Dry Run(仅打印请求,不发送) lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run ``` @@ -70,6 +76,7 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run | `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 | | `--attach ` | 否 | 附件文件路径,多个用逗号分隔。相对路径 | | `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | +| `--send-time ` | 否 | 定时发送时间。仅在 `--confirm-send` 下生效;如果未提供时区,默认按 UTC 处理。必须至少比当前时间晚 5 分钟 | | `--signature-id ` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 | | `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 | | `--dry-run` | 否 | 仅打印请求,不执行 | diff --git a/skills/lark-mail/references/lark-mail-reply.md b/skills/lark-mail/references/lark-mail-reply.md index 88cf0cc33..aa9e3f8ea 100644 --- a/skills/lark-mail/references/lark-mail-reply.md +++ b/skills/lark-mail/references/lark-mail-reply.md @@ -25,6 +25,8 @@ lark-cli mail +reply --message-id <邮件ID> --body '<回复正文>' ``` → 返回 `draft_id` +如果需要定时发送,请在 Step 1 使用 `--confirm-send --send-time `。 + **Step 2** — 向用户展示回复摘要(目标邮件、回复内容、收件人),请求确认发送 **Step 3** — 用户明确同意后,发送该草稿: @@ -55,6 +57,10 @@ lark-cli mail +reply --message-id <邮件ID> --body '收到' --from me@example.c # 确认发送回复(用户明确确认后使用) lark-cli mail +reply --message-id <邮件ID> --body '

收到,谢谢!

' --confirm-send +# 定时发送回复(用户明确确认后使用) +lark-cli mail +reply --message-id <邮件ID> --body '

收到,谢谢!

' \ + --confirm-send --send-time '2026-04-14T09:00:00+08:00' + # Dry Run(仅打印请求,不执行) lark-cli mail +reply --message-id <邮件ID> --body '

测试

' --dry-run ``` @@ -73,6 +79,7 @@ lark-cli mail +reply --message-id <邮件ID> --body '

测试

' --dry-run | `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 | | `--attach ` | 否 | 附件文件路径,多个用逗号分隔。相对路径 | | `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | +| `--send-time ` | 否 | 定时发送时间。仅在 `--confirm-send` 下生效;如果未提供时区,默认按 UTC 处理。必须至少比当前时间晚 5 分钟 | | `--signature-id ` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 | | `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 | | `--dry-run` | 否 | 仅打印请求,不执行 | diff --git a/skills/lark-mail/references/lark-mail-send.md b/skills/lark-mail/references/lark-mail-send.md index 25b301a6d..a79e53139 100644 --- a/skills/lark-mail/references/lark-mail-send.md +++ b/skills/lark-mail/references/lark-mail-send.md @@ -20,6 +20,12 @@ lark-cli mail +send --to <收件人> --subject '<主题>' --body '<正文>' ``` → 返回 `draft_id` +如果需要定时发送,请在 Step 1 使用 `--confirm-send --send-time `,例如: +```bash +lark-cli mail +send --to alice@example.com --subject '周报' --body '

本周进展如下...

' \ + --confirm-send --send-time '2026-04-14T09:00:00+08:00' +``` + **Step 2** — 向用户展示邮件摘要(收件人、主题、正文预览),请求确认发送 **Step 3** — 用户明确同意后,发送该草稿: @@ -43,6 +49,10 @@ lark-cli mail +send --to alice@example.com --cc bob@example.com --subject '状 lark-cli mail +send --to alice@example.com --subject '周报' \ --body '

本周进展如下...

' --confirm-send +# 定时发送(用户明确确认后使用) +lark-cli mail +send --to alice@example.com --subject '周报' \ + --body '

本周进展如下...

' --confirm-send --send-time '2026-04-14T09:00:00+08:00' + # 保存带附件的草稿 lark-cli mail +send --to alice@example.com --subject '请查收' --body '

见附件

' --attach ./report.pdf,./logs.zip @@ -70,6 +80,7 @@ lark-cli mail +send --to alice@example.com --subject '测试' --body '

test

` | 否 | 附件文件路径,多个用逗号分隔。相对路径 | | `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | +| `--send-time ` | 否 | 定时发送时间。仅在 `--confirm-send` 下生效;如果未提供时区,默认按 UTC 处理。必须至少比当前时间晚 5 分钟 | | `--signature-id ` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 | | `--confirm-send` | 否 | 确认发送邮件(默认只保存草稿)。仅在用户明确确认收件人和内容后使用 | | `--dry-run` | 否 | 仅打印请求,不执行 |