From 797ef8382c50cfbb782a73df74d93b90aa9d4239 Mon Sep 17 00:00:00 2001 From: "fengzhihao.infeng" Date: Thu, 16 Apr 2026 15:48:04 +0800 Subject: [PATCH 1/4] Reapply "feat: mail support scheduled send (#449)" (#492) This reverts commit 03ba542a60410d37bb52a5792da8445e5bd6416e. --- shortcuts/mail/draft/service.go | 8 +++-- shortcuts/mail/helpers.go | 22 ++++++++++++ shortcuts/mail/mail_forward.go | 7 +++- shortcuts/mail/mail_reply.go | 7 +++- shortcuts/mail/mail_reply_all.go | 7 +++- shortcuts/mail/mail_send.go | 7 +++- skill-template/domains/mail.md | 26 +++++++++----- skills/lark-mail/SKILL.md | 28 ++++++++++----- .../lark-mail/references/lark-mail-forward.md | 36 ++++++++++++++++++- .../references/lark-mail-reply-all.md | 36 ++++++++++++++++++- .../lark-mail/references/lark-mail-reply.md | 36 ++++++++++++++++++- skills/lark-mail/references/lark-mail-send.md | 34 ++++++++++++++++++ 12 files changed, 229 insertions(+), 25 deletions(-) diff --git a/shortcuts/mail/draft/service.go b/shortcuts/mail/draft/service.go index e62ab680f..34e668fe4 100644 --- a/shortcuts/mail/draft/service.go +++ b/shortcuts/mail/draft/service.go @@ -59,8 +59,12 @@ func UpdateWithRaw(runtime *common.RuntimeContext, mailboxID, draftID, rawEML st return err } -func Send(runtime *common.RuntimeContext, mailboxID, draftID string) (map[string]interface{}, error) { - return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, nil) +func Send(runtime *common.RuntimeContext, mailboxID, draftID, sendTime string) (map[string]interface{}, error) { + var bodyParams map[string]interface{} + if sendTime != "" { + bodyParams = map[string]interface{}{"send_time": sendTime} + } + return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, bodyParams) } func extractDraftID(data map[string]interface{}) string { diff --git a/shortcuts/mail/helpers.go b/shortcuts/mail/helpers.go index 0b1958d31..841bcc499 100644 --- a/shortcuts/mail/helpers.go +++ b/shortcuts/mail/helpers.go @@ -17,6 +17,7 @@ import ( "regexp" "strconv" "strings" + "time" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/auth" @@ -1906,6 +1907,27 @@ func checkAttachmentSizeLimit(fio fileio.FileIO, filePaths []string, extraBytes return nil } +// validateSendTime checks that --send-time, if provided, requires --confirm-send, +// is a valid Unix timestamp in seconds, and is at least 5 minutes in the future. +func validateSendTime(runtime *common.RuntimeContext) error { + sendTime := runtime.Str("send-time") + if sendTime == "" { + return nil + } + if !runtime.Bool("confirm-send") { + return fmt.Errorf("--send-time requires --confirm-send to be set") + } + ts, err := strconv.ParseInt(sendTime, 10, 64) + if err != nil { + return fmt.Errorf("--send-time must be a valid Unix timestamp in seconds, got %q", sendTime) + } + minTime := time.Now().Unix() + 5*60 + if ts < minTime { + return fmt.Errorf("--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts) + } + return nil +} + // validateConfirmSendScope checks that the user's token includes the // mail:user_mailbox.message:send scope when --confirm-send is set. // This scope is not declared in the shortcut's static Scopes (to keep the diff --git a/shortcuts/mail/mail_forward.go b/shortcuts/mail/mail_forward.go index 7337dcc1d..c7051c459 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: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."}, signatureFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { messageId := runtime.Str("message-id") @@ -59,6 +60,9 @@ var MailForward = common.Shortcut{ if err := validateConfirmSendScope(runtime); err != nil { return err } + if err := validateSendTime(runtime); 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 +83,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) @@ -231,7 +236,7 @@ var MailForward = common.Shortcut{ hintSendDraft(runtime, mailboxID, draftID) return nil } - resData, err := draftpkg.Send(runtime, mailboxID, draftID) + resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime) if err != nil { return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftID, err) } diff --git a/shortcuts/mail/mail_reply.go b/shortcuts/mail/mail_reply.go index 52e901087..e54022a9b 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: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."}, signatureFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { messageId := runtime.Str("message-id") @@ -56,6 +57,9 @@ var MailReply = common.Shortcut{ if err := validateConfirmSendScope(runtime); err != nil { return err } + if err := validateSendTime(runtime); err != nil { + return err + } if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil { return err } @@ -71,6 +75,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 { @@ -194,7 +199,7 @@ var MailReply = common.Shortcut{ hintSendDraft(runtime, mailboxID, draftID) return nil } - resData, err := draftpkg.Send(runtime, mailboxID, draftID) + resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime) if err != nil { return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftID, err) } diff --git a/shortcuts/mail/mail_reply_all.go b/shortcuts/mail/mail_reply_all.go index ab8b44298..0f719459d 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: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."}, signatureFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { messageId := runtime.Str("message-id") @@ -57,6 +58,9 @@ var MailReplyAll = common.Shortcut{ if err := validateConfirmSendScope(runtime); err != nil { return err } + if err := validateSendTime(runtime); err != nil { + return err + } if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil { return err } @@ -73,6 +77,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 { @@ -208,7 +213,7 @@ var MailReplyAll = common.Shortcut{ hintSendDraft(runtime, mailboxID, draftID) return nil } - resData, err := draftpkg.Send(runtime, mailboxID, draftID) + resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime) if err != nil { return fmt.Errorf("failed to send reply-all (draft %s created but not sent): %w", draftID, err) } diff --git a/shortcuts/mail/mail_send.go b/shortcuts/mail/mail_send.go index 1231f2f37..c6671cf36 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: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."}, signatureFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { to := runtime.Str("to") @@ -62,6 +63,9 @@ var MailSend = common.Shortcut{ if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil { return err } + if err := validateSendTime(runtime); err != nil { + return err + } if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil { return err } @@ -77,6 +81,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") @@ -162,7 +167,7 @@ var MailSend = common.Shortcut{ hintSendDraft(runtime, mailboxID, draftID) return nil } - resData, err := draftpkg.Send(runtime, mailboxID, draftID) + resData, err := draftpkg.Send(runtime, mailboxID, draftID, sendTime) if err != nil { return fmt.Errorf("failed to send email (draft %s created but not sent): %w", draftID, err) } diff --git a/skill-template/domains/mail.md b/skill-template/domains/mail.md index 23656fc7a..276113689 100644 --- a/skill-template/domains/mail.md +++ b/skill-template/domains/mail.md @@ -42,7 +42,7 @@ 4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送) 5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送) 6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送 -7. **确认投递** — 发送后用 `send_status` 查询投递状态,向用户报告结果 +7. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send` 8. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op ### CRITICAL — 首次使用任何命令前先查 `-h` @@ -104,15 +104,17 @@ 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` | `+reply --confirm-send --send-time ` 或 `+reply-all --confirm-send --send-time ` | +| **转发** | `+forward` | `+forward --confirm-send` | `+forward --confirm-send --send-time ` | - 有原邮件上下文 → 用 `+reply` / `+reply-all` / `+forward`(默认即草稿),**不要用 `+draft-create`** - **发送前必须向用户确认收件人和内容,用户明确同意后才可加 `--confirm-send`** -- **发送后必须调用 `send_status` 确认投递状态**(详见下方说明) +- **立即发送后必须调用 `send_status` 确认投递状态**;定时发送(`--send-time`)在预定发送时间后再查询,取消定时发送用 `cancel_scheduled_send`(详见下方说明) + +> **定时发送注意事项**:`--send-time` 必须与 `--confirm-send` 配合使用,不能单独使用。`send_time` 为 Unix 时间戳(秒),需至少为当前时间 + 5 分钟。 ### 使用公共邮箱或别名(send_as)发信 @@ -151,7 +153,7 @@ lark-cli mail +send --mailbox me --from alias@example.com \ ### 发送后确认投递状态 -邮件发送成功后(收到 `message_id`),**必须**调用 `send_status` API 查询投递状态并向用户报告: +**立即发送(无 `--send-time`)**:邮件发送成功后(收到 `message_id`),**必须**调用 `send_status` API 查询投递状态并向用户报告: ```bash lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me","message_id":"<发送返回的 message_id>"}' @@ -159,6 +161,14 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me 返回每个收件人的投递状态(`status`):1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告结果,如有异常状态(退信/审批拒绝)需重点提示。 +**定时发送(指定了 `--send-time`)**:定时发送不会立即产生 `message_id`,`send_status` 在定时发送成功后会返回"待发送"状态,**不建议在定时发送后立即查询**。可在预定发送时间后再查询。如需取消定时发送: + +```bash +lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":""}' +``` + +**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。 + ### 撤回邮件 发送成功后,若响应中包含 `recall_available: true`,说明该邮件支持撤回(24 小时内已投递的邮件)。 diff --git a/skills/lark-mail/SKILL.md b/skills/lark-mail/SKILL.md index 2d047f201..0716d713f 100644 --- a/skills/lark-mail/SKILL.md +++ b/skills/lark-mail/SKILL.md @@ -56,7 +56,7 @@ metadata: 4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送) 5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送) 6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送 -7. **确认投递** — 发送后用 `send_status` 查询投递状态,向用户报告结果 +7. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send` 8. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op ### CRITICAL — 首次使用任何命令前先查 `-h` @@ -118,15 +118,17 @@ 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` | `+reply --confirm-send --send-time ` 或 `+reply-all --confirm-send --send-time ` | +| **转发** | `+forward` | `+forward --confirm-send` | `+forward --confirm-send --send-time ` | - 有原邮件上下文 → 用 `+reply` / `+reply-all` / `+forward`(默认即草稿),**不要用 `+draft-create`** - **发送前必须向用户确认收件人和内容,用户明确同意后才可加 `--confirm-send`** -- **发送后必须调用 `send_status` 确认投递状态**(详见下方说明) +- **立即发送后必须调用 `send_status` 确认投递状态**;定时发送(`--send-time`)在预定发送时间后再查询,取消定时发送用 `cancel_scheduled_send`(详见下方说明) + +> **定时发送注意事项**:`--send-time` 必须与 `--confirm-send` 配合使用,不能单独使用。`send_time` 为 Unix 时间戳(秒),需至少为当前时间 + 5 分钟。 ### 使用公共邮箱或别名(send_as)发信 @@ -165,7 +167,7 @@ lark-cli mail +send --mailbox me --from alias@example.com \ ### 发送后确认投递状态 -邮件发送成功后(收到 `message_id`),**必须**调用 `send_status` API 查询投递状态并向用户报告: +**立即发送(无 `--send-time`)**:邮件发送成功后(收到 `message_id`),**必须**调用 `send_status` API 查询投递状态并向用户报告: ```bash lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me","message_id":"<发送返回的 message_id>"}' @@ -173,6 +175,14 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me 返回每个收件人的投递状态(`status`):1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告结果,如有异常状态(退信/审批拒绝)需重点提示。 +**定时发送(指定了 `--send-time`)**:定时发送不会立即产生 `message_id`,`send_status` 在定时发送成功后会返回"待发送"状态,**不建议在定时发送后立即查询**。可在预定发送时间后再查询。如需取消定时发送: + +```bash +lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":""}' +``` + +**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。 + ### 撤回邮件 发送成功后,若响应中包含 `recall_available: true`,说明该邮件支持撤回(24 小时内已投递的邮件)。 @@ -335,6 +345,7 @@ lark-cli mail [flags] # 调用 API ### user_mailbox.drafts + - `cancel_scheduled_send` — 取消定时发送 - `create` — 创建草稿 - `delete` — 删除指定邮箱账户下的单份邮件草稿。注意:对于草稿状态的邮件,只能使用本接口删除,禁止使用 trash_message;被删除的草稿数据无法恢复,请谨慎使用。 - `get` — 获取草稿详情 @@ -420,6 +431,7 @@ lark-cli mail [flags] # 调用 API | `user_mailboxes.accessible_mailboxes` | `mail:user_mailbox:readonly` | | `user_mailboxes.profile` | `mail:user_mailbox:readonly` | | `user_mailboxes.search` | `mail:user_mailbox.message:readonly` | +| `user_mailbox.drafts.cancel_scheduled_send` | `mail:user_mailbox.message:send` | | `user_mailbox.drafts.create` | `mail:user_mailbox.message:modify` | | `user_mailbox.drafts.delete` | `mail:user_mailbox.message:modify` | | `user_mailbox.drafts.get` | `mail:user_mailbox.message:readonly` | diff --git a/skills/lark-mail/references/lark-mail-forward.md b/skills/lark-mail/references/lark-mail-forward.md index ff145da9f..d26535875 100644 --- a/skills/lark-mail/references/lark-mail-forward.md +++ b/skills/lark-mail/references/lark-mail-forward.md @@ -68,6 +68,7 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run | `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | | `--signature-id ` | 否 | 签名 ID。附加邮箱签名到转发正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 | | `--confirm-send` | 否 | 确认发送转发(默认只保存草稿)。仅在用户明确确认后使用 | +| `--send-time ` | 否 | 定时发送时间,Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 | | `--dry-run` | 否 | 仅打印请求,不执行 | ## 返回值 @@ -114,6 +115,25 @@ lark-cli mail +forward --message-id <邮件ID> --to bob@example.com --body '

F lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":""}' ``` +### 场景 3:用户说"下午 3 点转发给 Bob"(定时发送) +```bash +# Step 1: 创建转发草稿 +lark-cli mail +forward --message-id <邮件ID> --to bob@example.com --body '

FYI,请查收。

' +# → 返回 draft_id + +# Step 2: 向用户确认 "转发草稿已创建:收件人 bob@example.com,定时 <目标时间> 发送。确认吗?" + +# Step 3: 用户确认后定时发送(send_time 为 Unix 时间戳,需至少当前时间 + 5 分钟) +lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":""}' --data '{"send_time":""}' +``` + +### 场景 4:用户说"等等,先不转发了"(取消定时发送) +```bash +# 取消定时发送(取消后邮件变回草稿) +lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":""}' +``` +→ 取消成功后邮件恢复为草稿状态,用户可重新编辑或在之后重新发送。 + ## 转发整个会话 `+forward` 操作的是单封邮件(`--message-id`),但转发整个会话时应 forward **会话中最后一条消息**,因为邮件客户端会将完整的回复链嵌套在最新一条中。典型流程: @@ -139,7 +159,9 @@ lark-cli mail +forward --message-id <最后一条的message_id> --to recipient@e 转发发送成功后: -**1. 确认投递状态**(必须)— 用返回的 `message_id` 查询投递状态: +**1. 确认投递状态**(仅立即发送 — 无 `--send-time` 时必须) + +用返回的 `message_id` 查询投递状态: ```bash lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me","message_id":"<发送返回的 message_id>"}' @@ -147,6 +169,18 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me 状态码:1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告投递结果,异常状态需重点提示。 +**1b. 定时发送(指定了 `--send-time`)** + +定时发送不会立即产生 `message_id`,因此 `send_status` 在定时发送成功后会返回"待发送"状态,**不建议在定时发送后立即查询**。可在预定发送时间后再查询。 + +如需取消定时发送: + +```bash +lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":""}' +``` + +**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。 + **2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意: ```bash diff --git a/skills/lark-mail/references/lark-mail-reply-all.md b/skills/lark-mail/references/lark-mail-reply-all.md index d22f1a12a..ec3d470f5 100644 --- a/skills/lark-mail/references/lark-mail-reply-all.md +++ b/skills/lark-mail/references/lark-mail-reply-all.md @@ -72,6 +72,7 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run | `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | | `--signature-id ` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 | | `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 | +| `--send-time ` | 否 | 定时发送时间,Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 | | `--dry-run` | 否 | 仅打印请求,不执行 | ## 返回值 @@ -118,6 +119,25 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '

已确认。

' lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":""}' ``` +### 场景 3:用户说"下午 3 点回复全部说已确认"(定时发送) +```bash +# Step 1: 创建回复全部草稿 +lark-cli mail +reply-all --message-id <邮件ID> --body '

已确认。

' +# → 返回 draft_id + +# Step 2: 向用户确认 "回复全部草稿已创建:收件人 alice@, bob@, carol@,内容「已确认。」定时 <目标时间> 发送。确认吗?" + +# Step 3: 用户确认后定时发送(send_time 为 Unix 时间戳,需至少当前时间 + 5 分钟) +lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":""}' --data '{"send_time":""}' +``` + +### 场景 4:用户说"等等,先不回复了"(取消定时发送) +```bash +# 取消定时发送(取消后邮件变回草稿) +lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":""}' +``` +→ 取消成功后邮件恢复为草稿状态,用户可重新编辑或在之后重新发送。 + ## 实现说明 - 自动收件人规则:原发件人优先进入 To,原 To/Cc 进入 Cc。 @@ -129,7 +149,9 @@ lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_ 回复发送成功后: -**1. 确认投递状态**(必须)— 用返回的 `message_id` 查询投递状态: +**1. 确认投递状态**(仅立即发送 — 无 `--send-time` 时必须) + +用返回的 `message_id` 查询投递状态: ```bash lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me","message_id":"<发送返回的 message_id>"}' @@ -137,6 +159,18 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me 状态码:1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告投递结果,异常状态需重点提示。 +**1b. 定时发送(指定了 `--send-time`)** + +定时发送不会立即产生 `message_id`,因此 `send_status` 在定时发送成功后会返回"待发送"状态,**不建议在定时发送后立即查询**。可在预定发送时间后再查询。 + +如需取消定时发送: + +```bash +lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":""}' +``` + +**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。 + **2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意: ```bash diff --git a/skills/lark-mail/references/lark-mail-reply.md b/skills/lark-mail/references/lark-mail-reply.md index 88cf0cc33..70b975582 100644 --- a/skills/lark-mail/references/lark-mail-reply.md +++ b/skills/lark-mail/references/lark-mail-reply.md @@ -75,6 +75,7 @@ lark-cli mail +reply --message-id <邮件ID> --body '

测试

' --dry-run | `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | | `--signature-id ` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 | | `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 | +| `--send-time ` | 否 | 定时发送时间,Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 | | `--dry-run` | 否 | 仅打印请求,不执行 | ## 返回值 @@ -121,6 +122,25 @@ lark-cli mail +reply --message-id <邮件ID> --body '

已处理,谢谢。

"}' ``` +### 场景 3:用户说"下午 3 点回复这封邮件说已处理"(定时发送) +```bash +# Step 1: 创建回复草稿 +lark-cli mail +reply --message-id <邮件ID> --body '

已处理,谢谢。

' +# → 返回 draft_id + +# Step 2: 向用户确认 "回复草稿已创建:回复给 alice@example.com,内容「已处理,谢谢。」定时 <目标时间> 发送。确认吗?" + +# Step 3: 用户确认后定时发送(send_time 为 Unix 时间戳,需至少当前时间 + 5 分钟) +lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":""}' --data '{"send_time":""}' +``` + +### 场景 4:用户说"等等,先不回复了"(取消定时发送) +```bash +# 取消定时发送(取消后邮件变回草稿) +lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":""}' +``` +→ 取消成功后邮件恢复为草稿状态,用户可重新编辑或在之后重新发送。 + ## 实现说明 ### 会话维护 @@ -144,7 +164,9 @@ References: <原邮件references + smtp_message_id> 回复发送成功后: -**1. 确认投递状态**(必须)— 用返回的 `message_id` 查询投递状态: +**1. 确认投递状态**(仅立即发送 — 无 `--send-time` 时必须) + +用返回的 `message_id` 查询投递状态: ```bash lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me","message_id":"<发送返回的 message_id>"}' @@ -152,6 +174,18 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me 状态码:1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告投递结果,异常状态需重点提示。 +**1b. 定时发送(指定了 `--send-time`)** + +定时发送不会立即产生 `message_id`,因此 `send_status` 在定时发送成功后会返回"待发送"状态,**不建议在定时发送后立即查询**。可在预定发送时间后再查询。 + +如需取消定时发送: + +```bash +lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":""}' +``` + +**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。 + **2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意: ```bash diff --git a/skills/lark-mail/references/lark-mail-send.md b/skills/lark-mail/references/lark-mail-send.md index 25b301a6d..a08c04809 100644 --- a/skills/lark-mail/references/lark-mail-send.md +++ b/skills/lark-mail/references/lark-mail-send.md @@ -72,6 +72,7 @@ lark-cli mail +send --to alice@example.com --subject '测试' --body '

test

` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | | `--signature-id ` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 | | `--confirm-send` | 否 | 确认发送邮件(默认只保存草稿)。仅在用户明确确认收件人和内容后使用 | +| `--send-time ` | 否 | 定时发送时间,Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 | | `--dry-run` | 否 | 仅打印请求,不执行 | ## 返回值 @@ -120,8 +121,29 @@ lark-cli mail +send --to alice@example.com --subject '收到' --body '

已收 lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":""}' ``` +### 场景 3:用户说"下午 3 点给 Alice 发一封周报"(定时发送) +```bash +# Step 1: 创建草稿(定时发送也走草稿流程) +lark-cli mail +send --to alice@example.com --subject '周报' --body '

本周进展如下...

' +# → 返回 draft_id + +# Step 2: 向用户确认 "邮件草稿已创建:收件人 alice@example.com,主题「周报」,定时 <目标时间> 发送。确认吗?" + +# Step 3: 用户确认后定时发送(send_time 为 Unix 时间戳,需至少当前时间 + 5 分钟) +lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":""}' --data '{"send_time":""}' +``` + +### 场景 4:用户说"等等,先不发那封邮件了"(取消定时发送) +```bash +# 取消定时发送(取消后邮件变回草稿) +lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":""}' +``` +→ 取消成功后邮件恢复为草稿状态,用户可重新编辑或在之后重新发送。 + ## 发送后跟进 +### 立即发送(无 `--send-time`) + 邮件发送成功后(收到 `message_id`),**必须**调用 `send_status` 查询投递状态: ```bash @@ -130,6 +152,18 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me 状态码:1=正在投递, 2=投递失败重试, 3=退信, 4=投递成功, 5=待审批, 6=审批拒绝。向用户简要报告各收件人投递结果,异常状态需重点提示。 +### 定时发送(指定了 `--send-time`) + +定时发送不会立即产生 `message_id`,因此 `send_status` 在定时发送成功后会返回"待发送"状态,**不建议在定时发送后立即查询**。可在预定发送时间后再查询投递状态。 + +如需取消定时发送,可在预定时间前调用取消接口: + +```bash +lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox_id":"me","draft_id":""}' +``` + +**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。 + ## 实现说明 - 使用 EML 构建器生成完整 MIME 邮件并 base64url 编码后发送。 From 77dd073b317988913219459af19e291f73ff1177 Mon Sep 17 00:00:00 2001 From: "fengzhihao.infeng" Date: Fri, 17 Apr 2026 14:28:39 +0800 Subject: [PATCH 2/4] test(mail): cover validateSendTime --- shortcuts/mail/helpers_test.go | 71 ++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/shortcuts/mail/helpers_test.go b/shortcuts/mail/helpers_test.go index fd243d9bd..b8087ab13 100644 --- a/shortcuts/mail/helpers_test.go +++ b/shortcuts/mail/helpers_test.go @@ -13,8 +13,10 @@ import ( "net/http/httptest" "os" "path/filepath" + "strconv" "strings" "testing" + "time" "github.com/spf13/cobra" @@ -1006,3 +1008,72 @@ func TestValidateComposeHasAtLeastOneRecipient_AlsoChecksCount(t *testing.T) { t.Fatalf("unexpected error message: %v", err) } } + +// --------------------------------------------------------------------------- +// validateSendTime +// --------------------------------------------------------------------------- + +func newSendTimeRuntime(t *testing.T, sendTime string, confirmSend bool) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("send-time", "", "") + cmd.Flags().Bool("confirm-send", false, "") + if sendTime != "" { + _ = cmd.Flags().Set("send-time", sendTime) + } + if confirmSend { + _ = cmd.Flags().Set("confirm-send", "true") + } + return &common.RuntimeContext{Cmd: cmd} +} + +func TestValidateSendTime_Empty(t *testing.T) { + rt := newSendTimeRuntime(t, "", false) + if err := validateSendTime(rt); err != nil { + t.Fatalf("expected nil when send-time is empty, got %v", err) + } +} + +func TestValidateSendTime_RequiresConfirmSend(t *testing.T) { + future := strconv.FormatInt(time.Now().Unix()+10*60, 10) + rt := newSendTimeRuntime(t, future, false) + err := validateSendTime(rt) + if err == nil { + t.Fatal("expected error when --send-time is set without --confirm-send") + } + if !strings.Contains(err.Error(), "--confirm-send") { + t.Errorf("expected error to mention --confirm-send, got: %v", err) + } +} + +func TestValidateSendTime_InvalidInteger(t *testing.T) { + rt := newSendTimeRuntime(t, "not-a-number", true) + err := validateSendTime(rt) + if err == nil { + t.Fatal("expected error when --send-time is not a valid integer") + } + if !strings.Contains(err.Error(), "Unix timestamp") { + t.Errorf("expected error to mention Unix timestamp, got: %v", err) + } +} + +func TestValidateSendTime_TooSoon(t *testing.T) { + // Just 1 minute in the future — below the 5-minute minimum. + soon := strconv.FormatInt(time.Now().Unix()+60, 10) + rt := newSendTimeRuntime(t, soon, true) + err := validateSendTime(rt) + if err == nil { + t.Fatal("expected error when --send-time is less than 5 minutes in the future") + } + if !strings.Contains(err.Error(), "5 minutes") { + t.Errorf("expected error to mention 5 minute minimum, got: %v", err) + } +} + +func TestValidateSendTime_Valid(t *testing.T) { + future := strconv.FormatInt(time.Now().Unix()+10*60, 10) + rt := newSendTimeRuntime(t, future, true) + if err := validateSendTime(rt); err != nil { + t.Fatalf("expected nil for valid future send-time, got %v", err) + } +} From b9a095762dcc43e88cf6ea1a6d80528f0299ccbd Mon Sep 17 00:00:00 2001 From: "fengzhihao.infeng" Date: Fri, 17 Apr 2026 14:40:33 +0800 Subject: [PATCH 3/4] chore: re-trigger CI From a9c1c3e666bf9808a222f71b59a4f1a653d6a37b Mon Sep 17 00:00:00 2001 From: "fengzhihao.infeng" Date: Fri, 17 Apr 2026 14:46:39 +0800 Subject: [PATCH 4/4] test(mail): integration tests for --send-time validation --- .../mail/mail_send_time_integration_test.go | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 shortcuts/mail/mail_send_time_integration_test.go diff --git a/shortcuts/mail/mail_send_time_integration_test.go b/shortcuts/mail/mail_send_time_integration_test.go new file mode 100644 index 000000000..b473acac8 --- /dev/null +++ b/shortcuts/mail/mail_send_time_integration_test.go @@ -0,0 +1,135 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "bytes" + "strconv" + "strings" + "testing" + "time" + + "github.com/zalando/go-keyring" + + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +// mailShortcutTestFactoryWithSendScope mirrors mailShortcutTestFactory but +// additionally grants the mail:user_mailbox.message:send scope so tests can +// exercise code paths guarded by validateConfirmSendScope (e.g. validateSendTime). +func mailShortcutTestFactoryWithSendScope(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + keyring.MockInit() + t.Setenv("HOME", t.TempDir()) + + cfg := mailTestConfig() + token := &auth.StoredUAToken{ + UserOpenId: cfg.UserOpenId, + AppId: cfg.AppID, + AccessToken: "test-user-access-token", + RefreshToken: "test-refresh-token", + ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(), + RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(), + Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:send mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly", + GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(), + } + if err := auth.SetStoredToken(token); err != nil { + t.Fatalf("SetStoredToken() error = %v", err) + } + t.Cleanup(func() { + _ = auth.RemoveStoredToken(cfg.AppID, cfg.UserOpenId) + }) + return cmdutil.TestFactory(t, cfg) +} + +// tooSoonSendTime returns a send-time 60s in the future — below the 5-minute +// floor enforced by validateSendTime. +func tooSoonSendTime() string { + return strconv.FormatInt(time.Now().Unix()+60, 10) +} + +// futureSendTime returns a send-time 10 minutes in the future — above the floor. +func futureSendTime() string { + return strconv.FormatInt(time.Now().Unix()+10*60, 10) +} + +// --------------------------------------------------------------------------- +// Invalid --send-time rejected by each compose shortcut +// --------------------------------------------------------------------------- + +func TestMailSend_SendTimeTooSoon(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t) + err := runMountedMailShortcut(t, MailSend, []string{ + "+send", "--to", "alice@example.com", "--subject", "hi", "--body", "hello", + "--confirm-send", "--send-time", tooSoonSendTime(), + }, f, stdout) + if err == nil { + t.Fatal("expected error for too-soon send-time, got nil") + } + if !strings.Contains(err.Error(), "5 minutes") { + t.Errorf("expected 5-minute error, got: %v", err) + } +} + +func TestMailReply_SendTimeTooSoon(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t) + err := runMountedMailShortcut(t, MailReply, []string{ + "+reply", "--message-id", "msg_001", "--body", "hello", + "--confirm-send", "--send-time", tooSoonSendTime(), + }, f, stdout) + if err == nil { + t.Fatal("expected error for too-soon send-time, got nil") + } + if !strings.Contains(err.Error(), "5 minutes") { + t.Errorf("expected 5-minute error, got: %v", err) + } +} + +func TestMailReplyAll_SendTimeTooSoon(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t) + err := runMountedMailShortcut(t, MailReplyAll, []string{ + "+reply-all", "--message-id", "msg_001", "--body", "hello", + "--confirm-send", "--send-time", tooSoonSendTime(), + }, f, stdout) + if err == nil { + t.Fatal("expected error for too-soon send-time, got nil") + } + if !strings.Contains(err.Error(), "5 minutes") { + t.Errorf("expected 5-minute error, got: %v", err) + } +} + +func TestMailForward_SendTimeTooSoon(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t) + err := runMountedMailShortcut(t, MailForward, []string{ + "+forward", "--message-id", "msg_001", "--to", "alice@example.com", + "--confirm-send", "--send-time", tooSoonSendTime(), + }, f, stdout) + if err == nil { + t.Fatal("expected error for too-soon send-time, got nil") + } + if !strings.Contains(err.Error(), "5 minutes") { + t.Errorf("expected 5-minute error, got: %v", err) + } +} + +// --------------------------------------------------------------------------- +// --send-time without --confirm-send is rejected up front +// --------------------------------------------------------------------------- + +func TestMailSend_SendTimeWithoutConfirmSend(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t) + err := runMountedMailShortcut(t, MailSend, []string{ + "+send", "--to", "alice@example.com", "--subject", "hi", "--body", "hello", + "--send-time", futureSendTime(), + }, f, stdout) + if err == nil { + t.Fatal("expected error for --send-time without --confirm-send, got nil") + } + if !strings.Contains(err.Error(), "--confirm-send") { + t.Errorf("expected error to mention --confirm-send, got: %v", err) + } +}