From d465e085b1c88d3ebcee3548344256b1113c1533 Mon Sep 17 00:00:00 2001 From: "fengzhihao.infeng" Date: Wed, 1 Apr 2026 22:21:12 +0800 Subject: [PATCH 1/3] Revert "fix(mail): clarify that file path flags only accept relative paths (#141)" This reverts commit 1ffe870dc8a5e75f2cfade5251bfff45043bca31. --- shortcuts/mail/mail_draft_create.go | 4 +-- shortcuts/mail/mail_draft_edit.go | 15 ++++++----- shortcuts/mail/mail_forward.go | 4 +-- shortcuts/mail/mail_reply.go | 4 +-- shortcuts/mail/mail_reply_all.go | 4 +-- shortcuts/mail/mail_send.go | 4 +-- .../references/lark-mail-draft-create.md | 4 +-- .../references/lark-mail-draft-edit.md | 25 +++++++++---------- .../lark-mail/references/lark-mail-forward.md | 8 +++--- .../references/lark-mail-reply-all.md | 4 +-- .../lark-mail/references/lark-mail-reply.md | 8 +++--- skills/lark-mail/references/lark-mail-send.md | 8 +++--- 12 files changed, 45 insertions(+), 47 deletions(-) diff --git a/shortcuts/mail/mail_draft_create.go b/shortcuts/mail/mail_draft_create.go index d6f706903..8b932795a 100644 --- a/shortcuts/mail/mail_draft_create.go +++ b/shortcuts/mail/mail_draft_create.go @@ -43,8 +43,8 @@ var MailDraftCreate = common.Shortcut{ {Name: "cc", Desc: "Optional. Full Cc recipient list. Separate multiple addresses with commas. Display-name format is supported."}, {Name: "bcc", Desc: "Optional. Full Bcc recipient list. Separate multiple addresses with commas. Display-name format is supported."}, {Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring HTML auto-detection. Cannot be used with --inline."}, - {Name: "attach", Desc: "Optional. Regular attachment file paths (relative path only). Separate multiple paths with commas. Each path must point to a readable local file."}, - {Name: "inline", Desc: "Optional. 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: "attach", Desc: "Optional. Regular attachment file paths. Separate multiple paths with commas. Each path must point to a readable local file."}, + {Name: "inline", Desc: "Optional. Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. 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\"."}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { input, err := parseDraftCreateInput(runtime) diff --git a/shortcuts/mail/mail_draft_edit.go b/shortcuts/mail/mail_draft_edit.go index 17a383726..b75dccc56 100644 --- a/shortcuts/mail/mail_draft_edit.go +++ b/shortcuts/mail/mail_draft_edit.go @@ -32,7 +32,7 @@ var MailDraftEdit = common.Shortcut{ {Name: "set-to", Desc: "Replace the entire To recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."}, {Name: "set-cc", Desc: "Replace the entire Cc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."}, {Name: "set-bcc", Desc: "Replace the entire Bcc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."}, - {Name: "patch-file", Desc: "Edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. All body edits MUST go through --patch-file. Two body ops: set_body (full replacement including quote) and set_reply_body (replaces only user-authored content, auto-preserves quote block). Run --inspect first to check has_quoted_content, then --print-patch-template for the JSON structure. Relative path only."}, + {Name: "patch-file", Desc: "Edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. All body edits MUST go through --patch-file. Two body ops: set_body (full replacement including quote) and set_reply_body (replaces only user-authored content, auto-preserves quote block). Run --inspect first to check has_quoted_content, then --print-patch-template for the JSON structure."}, {Name: "print-patch-template", Type: "bool", Desc: "Print the JSON template and supported operations for the --patch-file flag. Recommended first step before generating a patch file. No draft read or write is performed."}, {Name: "inspect", Type: "bool", Desc: "Inspect the draft without modifying it. Returns the draft projection including subject, recipients, body summary, has_quoted_content (whether the draft contains a reply/forward quote block), attachments_summary (with part_id and cid for each attachment), and inline_summary. Run this BEFORE editing body to check has_quoted_content: if true, use set_reply_body in --patch-file to preserve the quote; if false, use set_body."}, }, @@ -307,10 +307,10 @@ func buildDraftEditPatchTemplate() map[string]interface{} { {"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; the quote block is re-appended automatically; supports — local paths auto-resolved to inline MIME parts)"}}, {"op": "set_header", "shape": map[string]interface{}{"name": "string", "value": "string"}}, {"op": "remove_header", "shape": map[string]interface{}{"name": "string"}}, - {"op": "add_attachment", "shape": map[string]interface{}{"path": "string(relative path)"}}, + {"op": "add_attachment", "shape": map[string]interface{}{"path": "string"}}, {"op": "remove_attachment", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, - {"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}, "note": "advanced: prefer in set_body/set_reply_body instead"}, - {"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}}, + {"op": "add_inline", "shape": map[string]interface{}{"path": "string", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}}, + {"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}}, {"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, }, "supported_ops_by_group": []map[string]interface{}{ @@ -340,10 +340,10 @@ func buildDraftEditPatchTemplate() map[string]interface{} { { "group": "attachments_and_inline", "ops": []map[string]interface{}{ - {"op": "add_attachment", "shape": map[string]interface{}{"path": "string(relative path)"}}, + {"op": "add_attachment", "shape": map[string]interface{}{"path": "string"}}, {"op": "remove_attachment", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, - {"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}, "note": "advanced: prefer in set_body/set_reply_body instead"}, - {"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}}, + {"op": "add_inline", "shape": map[string]interface{}{"path": "string", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}}, + {"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}}, {"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, }, }, @@ -362,7 +362,6 @@ func buildDraftEditPatchTemplate() map[string]interface{} { "`set_body`/`set_reply_body` support inline images via local file paths: use in the HTML value — the local path is automatically resolved into an inline MIME part with a generated CID; removing or replacing an tag automatically cleans up or replaces the corresponding MIME part; do NOT use `add_inline` for this; example: {\"op\":\"set_body\",\"value\":\"
Hello
\"}", "`add_inline` is an advanced op for precise CID control only — in most cases, use in `set_body`/`set_reply_body` instead", "`ops` is executed in order", - "all file paths (--patch-file and `path` fields in ops) must be relative — no absolute paths or .. traversal", "all body edits MUST go through --patch-file; there is no --set-body flag", "`set_body` replaces the ENTIRE body including any reply/forward quote block; when the draft has both text/plain and text/html, it updates the HTML body and regenerates the plain-text summary, so the input should be HTML", "`set_reply_body` replaces only the user-authored portion of the body and automatically re-appends the trailing reply/forward quote block (generated by +reply or +forward); the value you pass should contain ONLY the new user-authored content WITHOUT the quote block — the quote block will be re-inserted automatically; if the user wants to modify content INSIDE the quote block, use `set_body` instead for full replacement; if the draft has no quote block, it behaves identically to `set_body`", diff --git a/shortcuts/mail/mail_forward.go b/shortcuts/mail/mail_forward.go index 905bc8afc..5b767e677 100644 --- a/shortcuts/mail/mail_forward.go +++ b/shortcuts/mail/mail_forward.go @@ -30,8 +30,8 @@ var MailForward = common.Shortcut{ {Name: "cc", Desc: "CC email address(es), comma-separated"}, {Name: "bcc", Desc: "BCC email address(es), comma-separated"}, {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: "attach", Desc: "Attachment file path(s), comma-separated (appended after original attachments)"}, + {Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. 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."}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { diff --git a/shortcuts/mail/mail_reply.go b/shortcuts/mail/mail_reply.go index b89bc5d69..638665e2d 100644 --- a/shortcuts/mail/mail_reply.go +++ b/shortcuts/mail/mail_reply.go @@ -28,8 +28,8 @@ var MailReply = common.Shortcut{ {Name: "cc", Desc: "Additional CC email address(es), comma-separated"}, {Name: "bcc", Desc: "BCC email address(es), comma-separated"}, {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: "attach", Desc: "Attachment file path(s), comma-separated"}, + {Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. 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."}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { diff --git a/shortcuts/mail/mail_reply_all.go b/shortcuts/mail/mail_reply_all.go index 6b82365ef..91d600bf0 100644 --- a/shortcuts/mail/mail_reply_all.go +++ b/shortcuts/mail/mail_reply_all.go @@ -29,8 +29,8 @@ var MailReplyAll = common.Shortcut{ {Name: "bcc", Desc: "BCC email address(es), comma-separated"}, {Name: "remove", Desc: "Address(es) to exclude from the outgoing reply, comma-separated"}, {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: "attach", Desc: "Attachment file path(s), comma-separated"}, + {Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. 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."}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { diff --git a/shortcuts/mail/mail_send.go b/shortcuts/mail/mail_send.go index 43b63826b..71c429df1 100644 --- a/shortcuts/mail/mail_send.go +++ b/shortcuts/mail/mail_send.go @@ -28,8 +28,8 @@ var MailSend = common.Shortcut{ {Name: "cc", Desc: "CC email address(es), comma-separated"}, {Name: "bcc", Desc: "BCC email address(es), comma-separated"}, {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: "attach", Desc: "Attachment file path(s), comma-separated"}, + {Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. 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."}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { diff --git a/skills/lark-mail/references/lark-mail-draft-create.md b/skills/lark-mail/references/lark-mail-draft-create.md index 7a33cd7cb..86ea2841e 100644 --- a/skills/lark-mail/references/lark-mail-draft-create.md +++ b/skills/lark-mail/references/lark-mail-draft-create.md @@ -48,8 +48,8 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te | `--cc ` | 否 | 完整抄送列表,多个用逗号分隔 | | `--bcc ` | 否 | 完整密送列表,多个用逗号分隔 | | `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用 | -| `--attach ` | 否 | 普通附件文件路径,多个用逗号分隔。相对路径 | -| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | +| `--attach ` | 否 | 普通附件文件路径,多个用逗号分隔 | +| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | | `--format ` | 否 | 输出格式:`json`(默认)/ `pretty` / `table` / `ndjson` / `csv` | | `--dry-run` | 否 | 仅打印请求,不执行 | diff --git a/skills/lark-mail/references/lark-mail-draft-edit.md b/skills/lark-mail/references/lark-mail-draft-edit.md index 5cb58d29f..adb092f65 100644 --- a/skills/lark-mail/references/lark-mail-draft-edit.md +++ b/skills/lark-mail/references/lark-mail-draft-edit.md @@ -69,7 +69,7 @@ lark-cli mail +draft-edit --draft-id --set-subject '测试' --dry-run | `--set-to ` | 否 | 用此处提供的地址替换整个 To 收件人列表 | | `--set-cc ` | 否 | 用此处提供的地址替换整个 Cc 抄送列表 | | `--set-bcc ` | 否 | 用此处提供的地址替换整个 Bcc 密送列表 | -| `--patch-file ` | 否 | 所有正文编辑、增量收件人编辑、邮件头编辑、附件变更和内嵌图片变更的入口。相对路径。先运行 `--print-patch-template` 查看 JSON 结构 | +| `--patch-file ` | 否 | 所有正文编辑、增量收件人编辑、邮件头编辑、附件变更和内嵌图片变更的入口。先运行 `--print-patch-template` 查看 JSON 结构 | | `--print-patch-template` | 否 | 打印 `--patch-file` 的 JSON 模板和支持的操作。建议在生成补丁文件前先运行此命令。不会读取或写入草稿 | | `--inspect` | 否 | 查看草稿但不修改。返回包含 `has_quoted_content`(是否有引用区)、`attachments_summary`(含每个附件的 `part_id`、`cid`、`filename`)和 `inline_summary` 的草稿投影 | | `--format ` | 否 | 输出格式:`json`(默认)/ `pretty` / `table` / `ndjson` / `csv` | @@ -222,7 +222,6 @@ lark-cli mail +draft-edit --draft-id --inspect - `ops` 按顺序执行 - `target` 接受 `part_id` 或 `cid`;优先级:`part_id` > `cid` -- **所有文件路径(`--patch-file` 及 ops 中的 `path`)必须为相对路径** - **正文编辑没有 flag,必须通过 `--patch-file`** - **`set_body` 是完整替换** — 它替换整个正文内容(包括引用区) - **`set_reply_body` 仅替换引用区前面的用户撰写部分** — 引用区自动重新拼接;value 只传用户撰写内容,不要包含引用区;如果用户要修改引用区内容,用 `set_body` 全量覆盖 @@ -251,10 +250,10 @@ lark-cli mail +draft-edit --draft-id --inspect lark-cli mail +draft-edit --draft-id --inspect # 2. 编辑草稿(元数据用 flag,正文用 patch-file) -cat > ./patch.json << 'EOF' +cat > /tmp/patch.json << 'EOF' { "ops": [{ "op": "set_body", "value": "

更新后的内容

" }] } EOF -lark-cli mail +draft-edit --draft-id --set-subject '最终版本' --patch-file ./patch.json +lark-cli mail +draft-edit --draft-id --set-subject '最终版本' --patch-file /tmp/patch.json # 3. 发送草稿 lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":""}' @@ -272,10 +271,10 @@ lark-cli mail +draft-edit --draft-id --inspect # body_html_summary: "
原有回复内容
..." # 2. 使用 set_reply_body 编辑正文(value 只传用户撰写内容,不含引用区) -cat > ./patch.json << 'EOF' +cat > /tmp/patch.json << 'EOF' { "ops": [{ "op": "set_reply_body", "value": "

修改后的回复内容

" }] } EOF -lark-cli mail +draft-edit --draft-id --patch-file ./patch.json +lark-cli mail +draft-edit --draft-id --patch-file /tmp/patch.json ``` **注意:** 如果误用 `set_body`,引用区将被覆盖丢失。如果用户明确要去掉引用区或修改引用区内容,则应使用 `set_body`。 @@ -289,7 +288,7 @@ lark-cli mail +draft-edit --draft-id --inspect # [{"part_id":"1.3","filename":"report.pdf","content_type":"application/pdf"}] # 2. 编写补丁文件,使用步骤 1 中获取的 part_id -cat > ./patch.json << 'EOF' +cat > /tmp/patch.json << 'EOF' { "ops": [ { "op": "remove_attachment", "target": { "part_id": "1.3" } } @@ -299,7 +298,7 @@ cat > ./patch.json << 'EOF' EOF # 3. 应用补丁 -lark-cli mail +draft-edit --draft-id --patch-file ./patch.json +lark-cli mail +draft-edit --draft-id --patch-file /tmp/patch.json ``` ### 在正文中插入内嵌图片 @@ -310,8 +309,8 @@ lark-cli mail +draft-edit --draft-id --patch-file ./patch.json # 1. 查看草稿以获取当前 HTML 正文 lark-cli mail +draft-edit --draft-id --inspect -# 2. 编写补丁 — 直接使用本地文件路径(注意:回复草稿用 set_reply_body,普通草稿用 set_body) -cat > ./patch.json << 'EOF' +# 2. 编写补丁(注意:回复草稿用 set_reply_body,普通草稿用 set_body) +cat > /tmp/patch.json << 'EOF' { "ops": [ { "op": "set_body", "value": "
内容
" } @@ -320,7 +319,7 @@ cat > ./patch.json << 'EOF' EOF # 3. 应用补丁 -lark-cli mail +draft-edit --draft-id --patch-file ./patch.json +lark-cli mail +draft-edit --draft-id --patch-file /tmp/patch.json ``` 内嵌图片的增删改通过 HTML 正文自动联动: @@ -337,7 +336,7 @@ lark-cli mail +draft-edit --draft-id --patch-file ./patch.json lark-cli mail +draft-edit --print-patch-template # 2. 编写补丁文件(例如添加一个抄送并移除一个附件) -cat > ./patch.json << 'EOF' +cat > /tmp/patch.json << 'EOF' { "ops": [ { "op": "add_recipient", "field": "cc", "address": "carol@example.com", "name": "Carol" }, @@ -348,7 +347,7 @@ cat > ./patch.json << 'EOF' EOF # 3. 应用补丁 -lark-cli mail +draft-edit --draft-id --patch-file ./patch.json +lark-cli mail +draft-edit --draft-id --patch-file /tmp/patch.json ``` ## 相关命令 diff --git a/skills/lark-mail/references/lark-mail-forward.md b/skills/lark-mail/references/lark-mail-forward.md index 81fbc2c60..42cd97173 100644 --- a/skills/lark-mail/references/lark-mail-forward.md +++ b/skills/lark-mail/references/lark-mail-forward.md @@ -63,8 +63,8 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run | `--cc ` | 否 | 抄送邮箱,多个用逗号分隔 | | `--bcc ` | 否 | 密送邮箱,多个用逗号分隔 | | `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 | -| `--attach ` | 否 | 附件文件路径,多个用逗号分隔,追加在原邮件附件之后。相对路径 | -| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | +| `--attach ` | 否 | 附件文件路径,多个用逗号分隔(追加在原邮件附件之后) | +| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | | `--confirm-send` | 否 | 确认发送转发(默认只保存草稿)。仅在用户明确确认后使用 | | `--dry-run` | 否 | 仅打印请求,不执行 | @@ -157,10 +157,10 @@ lark-cli mail user_mailbox.messages batch_modify_message --params '{"user_mailbo ```bash # 编辑转发草稿正文(自动保留引用区) -cat > ./patch.json << 'EOF' +cat > /tmp/patch.json << 'EOF' { "ops": [{ "op": "set_reply_body", "value": "

修改后的转发附言

" }] } EOF -lark-cli mail +draft-edit --draft-id --patch-file ./patch.json +lark-cli mail +draft-edit --draft-id --patch-file /tmp/patch.json ``` 如果用户要修改引用区内容或去掉引用区,则使用 `set_body` 全量替换。 diff --git a/skills/lark-mail/references/lark-mail-reply-all.md b/skills/lark-mail/references/lark-mail-reply-all.md index c8a7f6020..576fab3fa 100644 --- a/skills/lark-mail/references/lark-mail-reply-all.md +++ b/skills/lark-mail/references/lark-mail-reply-all.md @@ -67,8 +67,8 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run | `--bcc ` | 否 | 密送邮箱,多个用逗号分隔 | | `--remove ` | 否 | 从自动聚合结果中排除的邮箱,多个用逗号分隔 | | `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 | -| `--attach ` | 否 | 附件文件路径,多个用逗号分隔。相对路径 | -| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | +| `--attach ` | 否 | 附件文件路径,多个用逗号分隔 | +| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | | `--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 3a23d365a..baa513067 100644 --- a/skills/lark-mail/references/lark-mail-reply.md +++ b/skills/lark-mail/references/lark-mail-reply.md @@ -70,8 +70,8 @@ lark-cli mail +reply --message-id <邮件ID> --body '

测试

' --dry-run | `--cc ` | 否 | 抄送邮箱,多个用逗号分隔 | | `--bcc ` | 否 | 密送邮箱,多个用逗号分隔 | | `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 | -| `--attach ` | 否 | 附件文件路径,多个用逗号分隔。相对路径 | -| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | +| `--attach ` | 否 | 附件文件路径,多个用逗号分隔 | +| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | | `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 | | `--dry-run` | 否 | 仅打印请求,不执行 | @@ -162,10 +162,10 @@ lark-cli mail user_mailbox.messages batch_modify_message --params '{"user_mailbo ```bash # 编辑回复草稿正文(自动保留引用区) -cat > ./patch.json << 'EOF' +cat > /tmp/patch.json << 'EOF' { "ops": [{ "op": "set_reply_body", "value": "

修改后的回复内容

" }] } EOF -lark-cli mail +draft-edit --draft-id --patch-file ./patch.json +lark-cli mail +draft-edit --draft-id --patch-file /tmp/patch.json ``` 如果用户要修改引用区内容或去掉引用区,则使用 `set_body` 全量替换。 diff --git a/skills/lark-mail/references/lark-mail-send.md b/skills/lark-mail/references/lark-mail-send.md index dd196c129..092e82924 100644 --- a/skills/lark-mail/references/lark-mail-send.md +++ b/skills/lark-mail/references/lark-mail-send.md @@ -67,8 +67,8 @@ lark-cli mail +send --to alice@example.com --subject '测试' --body '

test

` | 否 | 抄送邮箱,多个用逗号分隔 | | `--bcc ` | 否 | 密送邮箱,多个用逗号分隔 | | `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用 | -| `--attach ` | 否 | 附件文件路径,多个用逗号分隔。相对路径 | -| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid` 和 `file_path`(相对路径)。CID 为唯一标识符,可使用随机十六进制字符串(如 `a1b2c3d4e5f6a7b8c9d0`)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用 | +| `--attach ` | 否 | 附件文件路径,多个用逗号分隔 | +| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid` 和 `file_path`。CID 为唯一标识符,可使用随机十六进制字符串(如 `a1b2c3d4e5f6a7b8c9d0`)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用 | | `--confirm-send` | 否 | 确认发送邮件(默认只保存草稿)。仅在用户明确确认收件人和内容后使用 | | `--dry-run` | 否 | 仅打印请求,不执行 | @@ -131,8 +131,8 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me ## 实现说明 - 使用 EML 构建器生成完整 MIME 邮件并 base64url 编码后发送。 -- `--attach` 作为普通附件添加。相对路径。 -- `--inline` 接受 JSON 数组,每项需提供 `cid`(唯一标识符,可用随机十六进制字符串)和 `file_path`(相对路径),作为 inline part 嵌入邮件。 +- `--attach` 作为普通附件添加。 +- `--inline` 接受 JSON 数组,每项需提供 `cid`(唯一标识符,可用随机十六进制字符串)和 `file_path`,作为 inline part 嵌入邮件。 ## 相关命令 From b21a90ff58e02d748729567656a83fb70455275f Mon Sep 17 00:00:00 2001 From: "fengzhihao.infeng" Date: Wed, 1 Apr 2026 22:25:50 +0800 Subject: [PATCH 2/3] Revert "feat(mail): auto-resolve local image paths in draft body HTML (#81) (#139)" This reverts commit 70c72a2c028e2c254ca741c11cf741422ab6849d. --- shortcuts/mail/draft/patch.go | 216 ++--- .../mail/draft/patch_inline_resolve_test.go | 773 ------------------ shortcuts/mail/draft/patch_test.go | 14 +- shortcuts/mail/mail_draft_edit.go | 19 +- .../references/lark-mail-draft-edit.md | 28 +- 5 files changed, 79 insertions(+), 971 deletions(-) delete mode 100644 shortcuts/mail/draft/patch_inline_resolve_test.go diff --git a/shortcuts/mail/draft/patch.go b/shortcuts/mail/draft/patch.go index e2c570243..3fc6d3ddd 100644 --- a/shortcuts/mail/draft/patch.go +++ b/shortcuts/mail/draft/patch.go @@ -8,18 +8,12 @@ import ( "mime" "os" "path/filepath" - "regexp" "strings" - "github.com/google/uuid" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/mail/filecheck" ) -// imgSrcRegexp matches and captures the src value. -// It handles both single and double quotes. -var imgSrcRegexp = regexp.MustCompile(`(?i)]*?\s)?src\s*=\s*["']([^"']+)["']`) - var protectedHeaders = map[string]bool{ "message-id": true, "mime-version": true, @@ -39,10 +33,13 @@ func Apply(snapshot *DraftSnapshot, patch Patch) error { return err } } - if err := postProcessInlineImages(snapshot); err != nil { + if err := refreshSnapshot(snapshot); err != nil { + return err + } + if err := validateInlineCIDAfterApply(snapshot); err != nil { return err } - return refreshSnapshot(snapshot) + return validateOrphanedInlineCIDAfterApply(snapshot) } func applyOp(snapshot *DraftSnapshot, op PatchOp, options PatchOptions) error { @@ -526,25 +523,21 @@ func addAttachment(snapshot *DraftSnapshot, path string) error { return nil } -// loadAndAttachInline reads a local image file, validates its format, -// creates a MIME inline part, and attaches it to the snapshot's -// multipart/related container. If container is non-nil it is reused; -// otherwise the container is resolved from the snapshot. -func loadAndAttachInline(snapshot *DraftSnapshot, path, cid, fileName string, container *Part) (*Part, error) { +func addInline(snapshot *DraftSnapshot, path, cid, fileName, contentType string) error { safePath, err := validate.SafeInputPath(path) if err != nil { - return nil, fmt.Errorf("inline image %q: %w", path, err) + return fmt.Errorf("inline image %q: %w", path, err) } info, err := os.Stat(safePath) if err != nil { - return nil, fmt.Errorf("inline image %q: %w", path, err) + return err } if err := checkSnapshotAttachmentLimit(snapshot, info.Size(), nil); err != nil { - return nil, err + return err } content, err := os.ReadFile(safePath) if err != nil { - return nil, fmt.Errorf("inline image %q: %w", path, err) + return err } name := fileName if strings.TrimSpace(name) == "" { @@ -552,30 +545,23 @@ func loadAndAttachInline(snapshot *DraftSnapshot, path, cid, fileName string, co } detectedCT, err := filecheck.CheckInlineImageFormat(name, content) if err != nil { - return nil, fmt.Errorf("inline image %q: %w", path, err) + return err } - inline, err := newInlinePart(safePath, content, cid, name, detectedCT) + inline, err := newInlinePart(path, content, cid, fileName, detectedCT) if err != nil { - return nil, fmt.Errorf("inline image %q: %w", path, err) + return err } - if container == nil { - containerRef := primaryBodyRootRef(&snapshot.Body) - if containerRef == nil || *containerRef == nil { - return nil, fmt.Errorf("draft has no primary body container") - } - container, err = ensureInlineContainerRef(containerRef) - if err != nil { - return nil, fmt.Errorf("inline image %q: %w", path, err) - } + containerRef := primaryBodyRootRef(&snapshot.Body) + if containerRef == nil || *containerRef == nil { + return fmt.Errorf("draft has no primary body container") + } + container, err := ensureInlineContainerRef(containerRef) + if err != nil { + return err } container.Children = append(container.Children, inline) container.Dirty = true - return container, nil -} - -func addInline(snapshot *DraftSnapshot, path, cid, fileName, contentType string) error { - _, err := loadAndAttachInline(snapshot, path, cid, fileName, nil) - return err + return nil } func replaceInline(snapshot *DraftSnapshot, partID, path, cid, fileName, contentType string) error { @@ -776,9 +762,6 @@ func newInlinePart(path string, content []byte, cid, fileName, contentType strin if err := validate.RejectCRLF(cid, "inline cid"); err != nil { return nil, err } - if strings.ContainsAny(cid, " \t<>()") { - return nil, fmt.Errorf("inline cid %q contains invalid characters (spaces, tabs, angle brackets, or parentheses are not allowed)", cid) - } if err := validate.RejectCRLF(fileName, "inline filename"); err != nil { return nil, err } @@ -874,152 +857,59 @@ func removeHeader(headers *[]Header, name string) { *headers = next } -// uriSchemeRegexp matches a URI scheme (RFC 3986: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":"). -var uriSchemeRegexp = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.\-]*:`) - -// isLocalFileSrc returns true if src is a local file path. -// Any URI with a scheme (http:, cid:, data:, ftp:, blob:, file:, etc.) -// or protocol-relative URL (//host/...) is rejected. -func isLocalFileSrc(src string) bool { - trimmed := strings.TrimSpace(src) - if trimmed == "" { - return false - } - if strings.HasPrefix(trimmed, "//") { - return false - } - return !uriSchemeRegexp.MatchString(trimmed) -} - -// generateCID returns a random UUID string suitable for use as a Content-ID. -// UUIDs contain only [0-9a-f-], which is inherently RFC-safe and unique, -// avoiding all filename-derived encoding/collision issues. -func generateCID() (string, error) { - id, err := uuid.NewRandom() - if err != nil { - return "", fmt.Errorf("failed to generate CID: %w", err) +// validateInlineCIDAfterApply checks that all CID references in the HTML body +// resolve to actual inline MIME parts. This is called after Apply (editing) to +// prevent broken CID references, but NOT during Parse (where broken CIDs +// should not block opening the draft). +func validateInlineCIDAfterApply(snapshot *DraftSnapshot) error { + htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID) + if htmlPart == nil { + return nil } - return id.String(), nil -} - -// resolveLocalImgSrc scans HTML for references, -// creates MIME inline parts for each local file, and returns the HTML -// with those src attributes replaced by cid: URIs. -func resolveLocalImgSrc(snapshot *DraftSnapshot, html string) (string, error) { - matches := imgSrcRegexp.FindAllStringSubmatchIndex(html, -1) - if len(matches) == 0 { - return html, nil + refs := extractCIDRefs(string(htmlPart.Body)) + if len(refs) == 0 { + return nil } - - var container *Part - // Cache resolved paths so the same file is only attached once. - pathToCID := make(map[string]string) - - // Iterate in reverse so that index offsets remain valid after replacement. - for i := len(matches) - 1; i >= 0; i-- { - srcStart, srcEnd := matches[i][2], matches[i][3] - src := html[srcStart:srcEnd] - if !isLocalFileSrc(src) { + cids := make(map[string]bool) + for _, part := range flattenParts(snapshot.Body) { + if part == nil || part.ContentID == "" { continue } - - resolvedPath, err := validate.SafeInputPath(src) - if err != nil { - return "", fmt.Errorf("inline image %q: %w", src, err) - } - - cid, ok := pathToCID[resolvedPath] - if !ok { - fileName := filepath.Base(src) - cid, err = generateCID() - if err != nil { - return "", err - } - pathToCID[resolvedPath] = cid - - container, err = loadAndAttachInline(snapshot, src, cid, fileName, container) - if err != nil { - return "", err - } - } - - html = html[:srcStart] + "cid:" + cid + html[srcEnd:] - } - - return html, nil -} - -// removeOrphanedInlineParts removes inline MIME parts whose ContentID -// is not in the referencedCIDs set from all multipart/related containers. -func removeOrphanedInlineParts(root *Part, referencedCIDs map[string]bool) { - if root == nil { - return - } - if !strings.EqualFold(root.MediaType, "multipart/related") { - for _, child := range root.Children { - removeOrphanedInlineParts(child, referencedCIDs) - } - return + cids[strings.ToLower(part.ContentID)] = true } - kept := make([]*Part, 0, len(root.Children)) - for _, child := range root.Children { - if child == nil { - continue - } - if strings.EqualFold(child.ContentDisposition, "inline") && child.ContentID != "" { - if !referencedCIDs[strings.ToLower(child.ContentID)] { - root.Dirty = true - continue - } + for _, ref := range refs { + if !cids[strings.ToLower(ref)] { + return fmt.Errorf("html body references missing inline cid %q", ref) } - kept = append(kept, child) } - root.Children = kept + return nil } -// postProcessInlineImages is the unified post-processing step that: -// 1. Resolves local to inline CID parts. -// 2. Validates all CID references in HTML resolve to MIME parts. -// 3. Removes orphaned inline MIME parts no longer referenced by HTML. -func postProcessInlineImages(snapshot *DraftSnapshot) error { - htmlPart := findPrimaryBodyPart(snapshot.Body, "text/html") +// validateOrphanedInlineCIDAfterApply checks the reverse direction: every +// inline MIME part with a ContentID must be referenced by the HTML body. +// An orphaned inline part (CID exists but HTML has no ) will +// be displayed as an unexpected attachment by most mail clients. +func validateOrphanedInlineCIDAfterApply(snapshot *DraftSnapshot) error { + htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID) if htmlPart == nil { return nil } - - origHTML := string(htmlPart.Body) - // Note: if resolveLocalImgSrc returns an error after partially attaching - // inline parts to the snapshot, those parts are orphaned but will be - // cleaned up by removeOrphanedInlineParts on the next successful Apply. - html, err := resolveLocalImgSrc(snapshot, origHTML) - if err != nil { - return err - } - if html != origHTML { - htmlPart.Body = []byte(html) - htmlPart.Dirty = true - } - - refs := extractCIDRefs(html) + refs := extractCIDRefs(string(htmlPart.Body)) refSet := make(map[string]bool, len(refs)) for _, ref := range refs { refSet[strings.ToLower(ref)] = true } - - cidParts := make(map[string]bool) + var orphaned []string for _, part := range flattenParts(snapshot.Body) { if part == nil || part.ContentID == "" { continue } - cidParts[strings.ToLower(part.ContentID)] = true - } - - for _, ref := range refs { - if !cidParts[strings.ToLower(ref)] { - return fmt.Errorf("html body references missing inline cid %q", ref) + if !refSet[strings.ToLower(part.ContentID)] { + orphaned = append(orphaned, part.ContentID) } } - - removeOrphanedInlineParts(snapshot.Body, refSet) + if len(orphaned) > 0 { + return fmt.Errorf("inline MIME parts have no reference in the HTML body and will appear as unexpected attachments: orphaned cids %v; if you used set_body, make sure the new body preserves all existing cid:... references", orphaned) + } return nil } diff --git a/shortcuts/mail/draft/patch_inline_resolve_test.go b/shortcuts/mail/draft/patch_inline_resolve_test.go deleted file mode 100644 index 7c43886a3..000000000 --- a/shortcuts/mail/draft/patch_inline_resolve_test.go +++ /dev/null @@ -1,773 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package draft - -import ( - "os" - "regexp" - "strings" - "testing" -) - -// --------------------------------------------------------------------------- -// resolveLocalImgSrc — basic auto-resolve -// --------------------------------------------------------------------------- - -func TestResolveLocalImgSrcBasic(t *testing.T) { - chdirTemp(t) - os.WriteFile("logo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) - - snapshot := mustParseFixtureDraft(t, `Subject: Test -From: Alice -To: Bob -MIME-Version: 1.0 -Content-Type: text/html; charset=UTF-8 - -
Hello
-`) - err := Apply(snapshot, Patch{ - Ops: []PatchOp{{Op: "set_body", Value: `
Hello
`}}, - }) - if err != nil { - t.Fatalf("Apply() error = %v", err) - } - htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID) - if htmlPart == nil { - t.Fatal("HTML part not found") - } - body := string(htmlPart.Body) - if strings.Contains(body, "./logo.png") { - t.Fatal("local path should have been replaced") - } - // Extract the generated CID from the HTML body. - cidRe := regexp.MustCompile(`src="cid:([^"]+)"`) - m := cidRe.FindStringSubmatch(body) - if m == nil { - t.Fatalf("expected src to contain a cid: reference, got: %s", body) - } - cid := m[1] - // Verify MIME inline part was created with the matching CID. - found := false - for _, part := range flattenParts(snapshot.Body) { - if part != nil && part.ContentID == cid { - found = true - if part.MediaType != "image/png" { - t.Fatalf("expected image/png, got %q", part.MediaType) - } - } - } - if !found { - t.Fatalf("expected inline MIME part with CID %q to be created", cid) - } -} - -// --------------------------------------------------------------------------- -// resolveLocalImgSrc — multiple images -// --------------------------------------------------------------------------- - -func TestResolveLocalImgSrcMultipleImages(t *testing.T) { - chdirTemp(t) - os.WriteFile("a.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) - os.WriteFile("b.jpg", []byte{0xFF, 0xD8, 0xFF, 0xE0}, 0o644) - - snapshot := mustParseFixtureDraft(t, `Subject: Test -From: Alice -To: Bob -MIME-Version: 1.0 -Content-Type: text/html; charset=UTF-8 - -
empty
-`) - err := Apply(snapshot, Patch{ - Ops: []PatchOp{{Op: "set_body", Value: `
`}}, - }) - if err != nil { - t.Fatalf("Apply() error = %v", err) - } - htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID) - body := string(htmlPart.Body) - cidRe := regexp.MustCompile(`src="cid:([^"]+)"`) - matches := cidRe.FindAllStringSubmatch(body, -1) - if len(matches) != 2 { - t.Fatalf("expected 2 cid: references, got %d in: %s", len(matches), body) - } - if matches[0][1] == matches[1][1] { - t.Fatalf("expected different CIDs for different files, both got: %s", matches[0][1]) - } -} - -// --------------------------------------------------------------------------- -// resolveLocalImgSrc — skips cid/http/data URIs -// --------------------------------------------------------------------------- - -func TestResolveLocalImgSrcSkipsNonLocalSrc(t *testing.T) { - chdirTemp(t) - - snapshot := mustParseFixtureDraft(t, `Subject: Test -From: Alice -To: Bob -MIME-Version: 1.0 -Content-Type: multipart/related; boundary="rel" - ---rel -Content-Type: text/html; charset=UTF-8 - -
---rel -Content-Type: image/png; name=existing.png -Content-Disposition: inline; filename=existing.png -Content-ID: -Content-Transfer-Encoding: base64 - -cG5n ---rel-- -`) - htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID) - originalBody := string(htmlPart.Body) - - err := Apply(snapshot, Patch{ - Ops: []PatchOp{{Op: "set_body", Value: originalBody}}, - }) - if err != nil { - t.Fatalf("Apply() error = %v", err) - } - htmlPart = findPart(snapshot.Body, snapshot.PrimaryHTMLPartID) - if string(htmlPart.Body) != originalBody { - t.Fatalf("body should be unchanged, got: %s", string(htmlPart.Body)) - } -} - -// --------------------------------------------------------------------------- -// resolveLocalImgSrc — duplicate file names get unique CIDs -// --------------------------------------------------------------------------- - -func TestResolveLocalImgSrcDuplicateCID(t *testing.T) { - chdirTemp(t) - os.MkdirAll("sub", 0o755) - os.WriteFile("logo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) - os.WriteFile("sub/logo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) - - snapshot := mustParseFixtureDraft(t, `Subject: Test -From: Alice -To: Bob -MIME-Version: 1.0 -Content-Type: text/html; charset=UTF-8 - -
empty
-`) - err := Apply(snapshot, Patch{ - Ops: []PatchOp{{Op: "set_body", Value: `
`}}, - }) - if err != nil { - t.Fatalf("Apply() error = %v", err) - } - htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID) - body := string(htmlPart.Body) - cidRe := regexp.MustCompile(`src="cid:([^"]+)"`) - matches := cidRe.FindAllStringSubmatch(body, -1) - if len(matches) != 2 { - t.Fatalf("expected 2 cid: references, got %d in: %s", len(matches), body) - } - if matches[0][1] == matches[1][1] { - t.Fatalf("expected different CIDs for different files, both got: %s", matches[0][1]) - } -} - -// --------------------------------------------------------------------------- -// resolveLocalImgSrc — same file referenced multiple times reuses one CID -// --------------------------------------------------------------------------- - -func TestResolveLocalImgSrcSameFileReused(t *testing.T) { - chdirTemp(t) - os.WriteFile("logo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) - - snapshot := mustParseFixtureDraft(t, `Subject: Test -From: Alice -To: Bob -MIME-Version: 1.0 -Content-Type: text/html; charset=UTF-8 - -
empty
-`) - err := Apply(snapshot, Patch{ - Ops: []PatchOp{{Op: "set_body", Value: `

text

`}}, - }) - if err != nil { - t.Fatalf("Apply() error = %v", err) - } - htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID) - body := string(htmlPart.Body) - // Both references should resolve to the same CID. - cidRe := regexp.MustCompile(`src="cid:([^"]+)"`) - matches := cidRe.FindAllStringSubmatch(body, -1) - if len(matches) != 2 { - t.Fatalf("expected 2 cid: references, got %d in: %s", len(matches), body) - } - if matches[0][1] != matches[1][1] { - t.Fatalf("expected same CID reused, got %q and %q", matches[0][1], matches[1][1]) - } - // Count inline MIME parts — should be exactly 1. - var count int - for _, part := range flattenParts(snapshot.Body) { - if part != nil && strings.EqualFold(part.ContentDisposition, "inline") { - count++ - } - } - if count != 1 { - t.Fatalf("expected 1 inline part (reused), got %d", count) - } -} - -// --------------------------------------------------------------------------- -// resolveLocalImgSrc — non-image format rejected -// --------------------------------------------------------------------------- - -func TestResolveLocalImgSrcRejectsNonImage(t *testing.T) { - chdirTemp(t) - os.WriteFile("doc.txt", []byte("not an image"), 0o644) - - snapshot := mustParseFixtureDraft(t, `Subject: Test -From: Alice -To: Bob -MIME-Version: 1.0 -Content-Type: text/html; charset=UTF-8 - -
empty
-`) - err := Apply(snapshot, Patch{ - Ops: []PatchOp{{Op: "set_body", Value: `
`}}, - }) - if err == nil { - t.Fatal("expected error for non-image file") - } -} - -// --------------------------------------------------------------------------- -// orphan cleanup — delete inline image by removing from body -// --------------------------------------------------------------------------- - -func TestOrphanCleanupOnImgRemoval(t *testing.T) { - snapshot := mustParseFixtureDraft(t, `Subject: Inline -From: Alice -To: Bob -MIME-Version: 1.0 -Content-Type: multipart/related; boundary="rel" - ---rel -Content-Type: text/html; charset=UTF-8 - -
hello
---rel -Content-Type: image/png; name=logo.png -Content-Disposition: inline; filename=logo.png -Content-ID: -Content-Transfer-Encoding: base64 - -cG5n ---rel-- -`) - // Remove the tag from body. - err := Apply(snapshot, Patch{ - Ops: []PatchOp{{Op: "set_body", Value: "
hello
"}}, - }) - if err != nil { - t.Fatalf("Apply() error = %v", err) - } - for _, part := range flattenParts(snapshot.Body) { - if part != nil && part.ContentID == "logo" { - t.Fatal("expected orphaned inline part 'logo' to be removed") - } - } -} - -// --------------------------------------------------------------------------- -// orphan cleanup — replace inline image -// --------------------------------------------------------------------------- - -func TestOrphanCleanupOnImgReplace(t *testing.T) { - chdirTemp(t) - os.WriteFile("new.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) - - snapshot := mustParseFixtureDraft(t, `Subject: Inline -From: Alice -To: Bob -MIME-Version: 1.0 -Content-Type: multipart/related; boundary="rel" - ---rel -Content-Type: text/html; charset=UTF-8 - -
---rel -Content-Type: image/png; name=old.png -Content-Disposition: inline; filename=old.png -Content-ID: -Content-Transfer-Encoding: base64 - -cG5n ---rel-- -`) - // Replace old image reference with a new local file. - err := Apply(snapshot, Patch{ - Ops: []PatchOp{{Op: "set_body", Value: `
`}}, - }) - if err != nil { - t.Fatalf("Apply() error = %v", err) - } - var foundOld bool - var newInlineCount int - for _, part := range flattenParts(snapshot.Body) { - if part == nil { - continue - } - if part.ContentID == "old" { - foundOld = true - } - if strings.EqualFold(part.ContentDisposition, "inline") && part.ContentID != "" && part.ContentID != "old" { - newInlineCount++ - } - } - if foundOld { - t.Fatal("expected old inline part to be removed") - } - if newInlineCount != 1 { - t.Fatalf("expected 1 new inline part, got %d", newInlineCount) - } -} - -// --------------------------------------------------------------------------- -// set_reply_body — local path resolved, quote block preserved -// --------------------------------------------------------------------------- - -func TestSetReplyBodyResolvesLocalImgSrc(t *testing.T) { - chdirTemp(t) - os.WriteFile("photo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) - - snapshot := mustParseFixtureDraft(t, `Subject: Re: Hello -From: Alice -To: Bob -MIME-Version: 1.0 -Content-Type: text/html; charset=UTF-8 - -
original reply
quoted text
-`) - err := Apply(snapshot, Patch{ - Ops: []PatchOp{{Op: "set_reply_body", Value: `
new reply
`}}, - }) - if err != nil { - t.Fatalf("Apply() error = %v", err) - } - htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID) - if htmlPart == nil { - t.Fatal("HTML part not found") - } - body := string(htmlPart.Body) - if strings.Contains(body, "./photo.png") { - t.Fatal("local path should have been replaced") - } - cidRe := regexp.MustCompile(`src="cid:([^"]+)"`) - m := cidRe.FindStringSubmatch(body) - if m == nil { - t.Fatalf("expected cid: reference in body, got: %s", body) - } - if !strings.Contains(body, "history-quote-wrapper") { - t.Fatalf("expected quote block preserved, got: %s", body) - } - found := false - for _, part := range flattenParts(snapshot.Body) { - if part != nil && part.ContentID == m[1] { - found = true - } - } - if !found { - t.Fatalf("expected inline MIME part with CID %q to be created", m[1]) - } -} - -// --------------------------------------------------------------------------- -// mixed usage — add_inline + local path in body -// --------------------------------------------------------------------------- - -func TestMixedAddInlineAndLocalPath(t *testing.T) { - chdirTemp(t) - os.WriteFile("a.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) - os.WriteFile("b.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) - - snapshot := mustParseFixtureDraft(t, `Subject: Test -From: Alice -To: Bob -MIME-Version: 1.0 -Content-Type: text/html; charset=UTF-8 - -
empty
-`) - err := Apply(snapshot, Patch{ - Ops: []PatchOp{ - {Op: "add_inline", Path: "a.png", CID: "a"}, - {Op: "set_body", Value: `
`}, - }, - }) - if err != nil { - t.Fatalf("Apply() error = %v", err) - } - var foundA bool - var autoResolvedCount int - for _, part := range flattenParts(snapshot.Body) { - if part == nil { - continue - } - if part.ContentID == "a" { - foundA = true - } else if strings.EqualFold(part.ContentDisposition, "inline") && part.ContentID != "" { - autoResolvedCount++ - } - } - if !foundA { - t.Fatal("expected inline part 'a' from add_inline") - } - if autoResolvedCount != 1 { - t.Fatalf("expected 1 auto-resolved inline part for b.png, got %d", autoResolvedCount) - } -} - -// --------------------------------------------------------------------------- -// conflict: add_inline same file + body local path → redundant part cleaned -// --------------------------------------------------------------------------- - -func TestAddInlineSameFileAsLocalPath(t *testing.T) { - chdirTemp(t) - os.WriteFile("logo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) - - snapshot := mustParseFixtureDraft(t, `Subject: Test -From: Alice -To: Bob -MIME-Version: 1.0 -Content-Type: text/html; charset=UTF-8 - -
empty
-`) - // add_inline creates CID "logo", but body uses local path instead of cid:logo. - // resolve generates a UUID CID, orphan cleanup removes the unused "logo". - err := Apply(snapshot, Patch{ - Ops: []PatchOp{ - {Op: "add_inline", Path: "logo.png", CID: "logo"}, - {Op: "set_body", Value: `
`}, - }, - }) - if err != nil { - t.Fatalf("Apply() error = %v", err) - } - // The explicitly added "logo" CID is orphaned (not referenced in HTML) - // and should be auto-removed. Only the auto-generated CID remains. - var foundLogo bool - var count int - for _, part := range flattenParts(snapshot.Body) { - if part != nil && strings.EqualFold(part.ContentDisposition, "inline") { - count++ - if part.ContentID == "logo" { - foundLogo = true - } - } - } - if foundLogo { - t.Fatal("expected orphaned 'logo' inline part to be removed") - } - if count != 1 { - t.Fatalf("expected 1 inline part after orphan cleanup, got %d", count) - } -} - -// --------------------------------------------------------------------------- -// conflict: remove_inline but body still references its CID → error -// --------------------------------------------------------------------------- - -func TestRemoveInlineButBodyStillReferencesCID(t *testing.T) { - snapshot := mustParseFixtureDraft(t, `Subject: Inline -From: Alice -To: Bob -MIME-Version: 1.0 -Content-Type: multipart/related; boundary="rel" - ---rel -Content-Type: text/html; charset=UTF-8 - -
---rel -Content-Type: image/png; name=logo.png -Content-Disposition: inline; filename=logo.png -Content-ID: -Content-Transfer-Encoding: base64 - -cG5n ---rel-- -`) - // remove_inline removes the MIME part, but set_body still references cid:logo. - err := Apply(snapshot, Patch{ - Ops: []PatchOp{ - {Op: "remove_inline", Target: AttachmentTarget{CID: "logo"}}, - {Op: "set_body", Value: `
`}, - }, - }) - if err == nil || !strings.Contains(err.Error(), "missing inline cid") { - t.Fatalf("expected missing cid error, got: %v", err) - } -} - -// --------------------------------------------------------------------------- -// conflict: remove_inline + body replaces with local path → works -// --------------------------------------------------------------------------- - -func TestRemoveInlineAndReplaceWithLocalPath(t *testing.T) { - chdirTemp(t) - os.WriteFile("new.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) - - snapshot := mustParseFixtureDraft(t, `Subject: Inline -From: Alice -To: Bob -MIME-Version: 1.0 -Content-Type: multipart/related; boundary="rel" - ---rel -Content-Type: text/html; charset=UTF-8 - -
---rel -Content-Type: image/png; name=old.png -Content-Disposition: inline; filename=old.png -Content-ID: -Content-Transfer-Encoding: base64 - -cG5n ---rel-- -`) - err := Apply(snapshot, Patch{ - Ops: []PatchOp{ - {Op: "remove_inline", Target: AttachmentTarget{CID: "old"}}, - {Op: "set_body", Value: `
`}, - }, - }) - if err != nil { - t.Fatalf("Apply() error = %v", err) - } - var foundOld bool - var newInlineCount int - for _, part := range flattenParts(snapshot.Body) { - if part == nil { - continue - } - if part.ContentID == "old" { - foundOld = true - } - if strings.EqualFold(part.ContentDisposition, "inline") && part.ContentID != "" && part.ContentID != "old" { - newInlineCount++ - } - } - if foundOld { - t.Fatal("expected old inline part to be removed") - } - if newInlineCount != 1 { - t.Fatalf("expected 1 new inline part from local path resolve, got %d", newInlineCount) - } -} - -// --------------------------------------------------------------------------- -// no HTML body — text/plain only draft -// --------------------------------------------------------------------------- - -func TestResolveLocalImgSrcNoHTMLBody(t *testing.T) { - snapshot := mustParseFixtureDraft(t, `Subject: Plain -From: Alice -To: Bob -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 - -Just plain text. -`) - err := Apply(snapshot, Patch{ - Ops: []PatchOp{{Op: "set_body", Value: "Updated plain text."}}, - }) - if err != nil { - t.Fatalf("Apply() error = %v", err) - } -} - -// --------------------------------------------------------------------------- -// regression: HTML body with Content-ID must not be removed by orphan cleanup -// --------------------------------------------------------------------------- - -func TestOrphanCleanupPreservesHTMLBodyWithContentID(t *testing.T) { - snapshot := mustParseFixtureDraft(t, `Subject: Test -From: Alice -To: Bob -MIME-Version: 1.0 -Content-Type: multipart/related; boundary="rel" - ---rel -Content-Type: text/html; charset=UTF-8 -Content-ID: - -
hello world
---rel -Content-Type: image/png; name=logo.png -Content-Disposition: inline; filename=logo.png -Content-ID: -Content-Transfer-Encoding: base64 - -cG5n ---rel-- -`) - // A metadata-only edit should not destroy the HTML body part even though - // its Content-ID is not referenced by any . - err := Apply(snapshot, Patch{ - Ops: []PatchOp{{Op: "set_subject", Value: "Updated subject"}}, - }) - if err != nil { - t.Fatalf("Apply() error = %v", err) - } - htmlPart := findPrimaryBodyPart(snapshot.Body, "text/html") - if htmlPart == nil { - t.Fatal("HTML body part was deleted by orphan cleanup") - } - if !strings.Contains(string(htmlPart.Body), "hello world") { - t.Fatalf("HTML body content changed unexpectedly: %s", string(htmlPart.Body)) - } -} - -// --------------------------------------------------------------------------- -// helper unit tests -// --------------------------------------------------------------------------- - -func TestIsLocalFileSrc(t *testing.T) { - tests := []struct { - src string - want bool - }{ - {"./logo.png", true}, - {"../images/logo.png", true}, - {"logo.png", true}, - {"/absolute/path/logo.png", true}, - {"cid:logo", false}, - {"CID:logo", false}, - {"http://example.com/img.png", false}, - {"https://example.com/img.png", false}, - {"data:image/png;base64,abc", false}, - {"//cdn.example.com/a.png", false}, - {"blob:https://example.com/uuid", false}, - {"ftp://example.com/file.png", false}, - {"file:///local/file.png", false}, - {"mailto:test@example.com", false}, - {"", false}, - } - for _, tt := range tests { - if got := isLocalFileSrc(tt.src); got != tt.want { - t.Errorf("isLocalFileSrc(%q) = %v, want %v", tt.src, got, tt.want) - } - } -} - -func TestGenerateCID(t *testing.T) { - seen := make(map[string]bool) - for i := 0; i < 100; i++ { - cid, err := generateCID() - if err != nil { - t.Fatalf("generateCID() error = %v", err) - } - if cid == "" { - t.Fatal("generateCID() returned empty string") - } - if strings.ContainsAny(cid, " \t\r\n<>()") { - t.Fatalf("generateCID() returned CID with invalid characters: %q", cid) - } - if seen[cid] { - t.Fatalf("generateCID() returned duplicate CID: %q", cid) - } - seen[cid] = true - } -} - -// --------------------------------------------------------------------------- -// imgSrcRegexp — must not match data-src or similar attribute names -// --------------------------------------------------------------------------- - -func TestImgSrcRegexpSkipsDataSrc(t *testing.T) { - tests := []struct { - name string - html string - want string // expected captured src value, empty if no match - }{ - { - name: "plain src", - html: ``, - want: "./logo.png", - }, - { - name: "src with alt before", - html: `pic`, - want: "./logo.png", - }, - { - name: "data-src before real src", - html: ``, - want: "./logo.png", - }, - { - name: "only data-src, no src", - html: ``, - want: "", - }, - { - name: "x-src before real src", - html: ``, - want: "./real.png", - }, - { - name: "single-quoted src", - html: ``, - want: "./logo.png", - }, - { - name: "multiple spaces before src", - html: ``, - want: "./logo.png", - }, - { - name: "newline before src", - html: "", - want: "./logo.png", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - matches := imgSrcRegexp.FindStringSubmatch(tt.html) - got := "" - if len(matches) > 1 { - got = matches[1] - } - if got != tt.want { - t.Errorf("imgSrcRegexp on %q: got %q, want %q", tt.html, got, tt.want) - } - }) - } -} - -// --------------------------------------------------------------------------- -// newInlinePart — rejects CIDs with spaces or other invalid characters -// --------------------------------------------------------------------------- - -func TestNewInlinePartRejectsInvalidCIDChars(t *testing.T) { - content := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A} - for _, bad := range []string{"my logo", "a\tb", "cid", "cid(x)"} { - _, err := newInlinePart("test.png", content, bad, "test.png", "image/png") - if err == nil { - t.Errorf("expected error for CID %q, got nil", bad) - } - } - // Valid CIDs should pass. - for _, good := range []string{"logo", "my-logo", "img_01", "photo.2"} { - _, err := newInlinePart("test.png", content, good, "test.png", "image/png") - if err != nil { - t.Errorf("unexpected error for CID %q: %v", good, err) - } - } -} diff --git a/shortcuts/mail/draft/patch_test.go b/shortcuts/mail/draft/patch_test.go index e3c55a9e0..8572f503d 100644 --- a/shortcuts/mail/draft/patch_test.go +++ b/shortcuts/mail/draft/patch_test.go @@ -460,7 +460,7 @@ func TestRemoveInlineFailsWhenHTMLStillReferencesCID(t *testing.T) { } } -func TestApplySetBodyOrphanedInlineCIDIsAutoRemoved(t *testing.T) { +func TestApplySetBodyOrphanedInlineCIDIsRejected(t *testing.T) { snapshot := mustParseFixtureDraft(t, `Subject: Inline From: Alice To: Bob @@ -480,18 +480,12 @@ Content-Transfer-Encoding: base64 cG5n --rel-- `) - // set_body that drops the existing cid:logo reference → logo is auto-removed + // set_body that drops the existing cid:logo reference → logo becomes orphaned err := Apply(snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "
replaced body without cid reference
"}}, }) - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - // The orphaned inline part should be removed from the MIME tree. - for _, part := range flattenParts(snapshot.Body) { - if part != nil && part.ContentID == "logo" { - t.Fatal("expected orphaned inline part 'logo' to be removed") - } + if err == nil || !strings.Contains(err.Error(), "orphaned cids") { + t.Fatalf("expected orphaned cid error, got: %v", err) } } diff --git a/shortcuts/mail/mail_draft_edit.go b/shortcuts/mail/mail_draft_edit.go index b75dccc56..8f7868441 100644 --- a/shortcuts/mail/mail_draft_edit.go +++ b/shortcuts/mail/mail_draft_edit.go @@ -303,14 +303,14 @@ func buildDraftEditPatchTemplate() map[string]interface{} { {"op": "set_recipients", "shape": map[string]interface{}{"field": "to|cc|bcc", "addresses": []map[string]interface{}{{"address": "string", "name": "string(optional)"}}}}, {"op": "add_recipient", "shape": map[string]interface{}{"field": "to|cc|bcc", "address": "string", "name": "string(optional)"}}, {"op": "remove_recipient", "shape": map[string]interface{}{"field": "to|cc|bcc", "address": "string"}}, - {"op": "set_body", "shape": map[string]interface{}{"value": "string (supports — local paths auto-resolved to inline MIME parts)"}}, - {"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; the quote block is re-appended automatically; supports — local paths auto-resolved to inline MIME parts)"}}, + {"op": "set_body", "shape": map[string]interface{}{"value": "string"}}, + {"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; the quote block is re-appended automatically)"}}, {"op": "set_header", "shape": map[string]interface{}{"name": "string", "value": "string"}}, {"op": "remove_header", "shape": map[string]interface{}{"name": "string"}}, {"op": "add_attachment", "shape": map[string]interface{}{"path": "string"}}, {"op": "remove_attachment", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, - {"op": "add_inline", "shape": map[string]interface{}{"path": "string", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}}, - {"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}}, + {"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}}, + {"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}}, {"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, }, "supported_ops_by_group": []map[string]interface{}{ @@ -318,8 +318,8 @@ func buildDraftEditPatchTemplate() map[string]interface{} { "group": "subject_and_body", "ops": []map[string]interface{}{ {"op": "set_subject", "shape": map[string]interface{}{"value": "string"}}, - {"op": "set_body", "shape": map[string]interface{}{"value": "string (supports — local paths auto-resolved to inline MIME parts)"}}, - {"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; the quote block is re-appended automatically; supports — local paths auto-resolved to inline MIME parts)"}}, + {"op": "set_body", "shape": map[string]interface{}{"value": "string"}}, + {"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; the quote block is re-appended automatically)"}}, }, }, { @@ -342,8 +342,8 @@ func buildDraftEditPatchTemplate() map[string]interface{} { "ops": []map[string]interface{}{ {"op": "add_attachment", "shape": map[string]interface{}{"path": "string"}}, {"op": "remove_attachment", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, - {"op": "add_inline", "shape": map[string]interface{}{"path": "string", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}}, - {"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}}, + {"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}}, + {"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}}, {"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, }, }, @@ -359,12 +359,11 @@ func buildDraftEditPatchTemplate() map[string]interface{} { {"situation": "draft created by +reply or +forward (has_quoted_content=true)", "recommended_op": "set_reply_body — replaces only the user-authored portion and automatically preserves the quoted original message; if user explicitly wants to remove the quote, use set_body instead"}, }, "notes": []string{ - "`set_body`/`set_reply_body` support inline images via local file paths: use in the HTML value — the local path is automatically resolved into an inline MIME part with a generated CID; removing or replacing an tag automatically cleans up or replaces the corresponding MIME part; do NOT use `add_inline` for this; example: {\"op\":\"set_body\",\"value\":\"
Hello
\"}", - "`add_inline` is an advanced op for precise CID control only — in most cases, use in `set_body`/`set_reply_body` instead", "`ops` is executed in order", "all body edits MUST go through --patch-file; there is no --set-body flag", "`set_body` replaces the ENTIRE body including any reply/forward quote block; when the draft has both text/plain and text/html, it updates the HTML body and regenerates the plain-text summary, so the input should be HTML", "`set_reply_body` replaces only the user-authored portion of the body and automatically re-appends the trailing reply/forward quote block (generated by +reply or +forward); the value you pass should contain ONLY the new user-authored content WITHOUT the quote block — the quote block will be re-inserted automatically; if the user wants to modify content INSIDE the quote block, use `set_body` instead for full replacement; if the draft has no quote block, it behaves identically to `set_body`", + "`add_inline` only adds the MIME binary part; it does NOT insert an tag into the HTML body; to display the image in the body, you must ALSO use set_body/set_reply_body to insert into the body content; forgetting this causes the inline part to become an orphaned attachment when sent", "`body_kind` only supports text/plain and text/html", "`selector` currently only supports primary", "`remove_attachment` target supports part_id or cid; priority: part_id > cid", diff --git a/skills/lark-mail/references/lark-mail-draft-edit.md b/skills/lark-mail/references/lark-mail-draft-edit.md index adb092f65..31d2435f1 100644 --- a/skills/lark-mail/references/lark-mail-draft-edit.md +++ b/skills/lark-mail/references/lark-mail-draft-edit.md @@ -198,9 +198,9 @@ lark-cli mail +draft-edit --draft-id --inspect { "op": "add_inline", "path": "./logo.png", "cid": "logo" } ``` -> **推荐方式:** 直接在 `set_body`/`set_reply_body` 的 HTML 中使用 ``(本地文件路径),系统会自动创建 MIME 内嵌部分、生成 CID 并替换为 `cid:` 引用。删除或替换 `` 标签时,对应的 MIME 部分会自动清理。详见[在正文中插入内嵌图片](#在正文中插入内嵌图片)。 -> -> `add_inline` 仅在需要精确控制 CID 命名时使用。使用时仍需在 HTML 正文中加入 `` 引用。 +> **重要:`add_inline` 仅添加 MIME 二进制部分,不会在 HTML 正文中插入 `` 标签。** +> 如需图片在邮件正文中可见,**必须**同时使用 `set_body` 或 `set_reply_body` 更新 HTML 正文并加入 `` 标签。参见[在正文中插入内嵌图片](#在正文中插入内嵌图片)的完整流程。 +> 如果忘记添加 `` 引用,该内嵌部分在发送时会变成孤立附件。 `replace_inline` @@ -303,18 +303,23 @@ lark-cli mail +draft-edit --draft-id --patch-file /tmp/patch.json ### 在正文中插入内嵌图片 -直接在 `set_body`/`set_reply_body` 的 HTML 中使用本地文件路径即可。系统会自动创建 MIME 内嵌部分并替换为 `cid:` 引用。 +添加内嵌图片需要**两个协同编辑**:(1)通过 `add_inline` 添加 MIME 部分,(2)通过 `set_body` 或 `set_reply_body` 在 HTML 正文中插入 `` 标签。 ```bash -# 1. 查看草稿以获取当前 HTML 正文 +# 1. 查看草稿以获取当前 HTML 正文和已有的内嵌部分 lark-cli mail +draft-edit --draft-id --inspect +# 返回包含: +# projection.body_html_summary: "
原有内容
" +# projection.inline_summary: [{"part_id":"1.1.2","cid":"existing.png", ...}] # 2. 编写补丁(注意:回复草稿用 set_reply_body,普通草稿用 set_body) -cat > /tmp/patch.json << 'EOF' +cat > ./patch.json << 'EOF' { "ops": [ - { "op": "set_body", "value": "
内容
" } - ] + { "op": "set_body", "value": "
原有内容
" }, + { "op": "add_inline", "path": "./new-image.png", "cid": "new-image" } + ], + "options": {} } EOF @@ -322,13 +327,6 @@ EOF lark-cli mail +draft-edit --draft-id --patch-file /tmp/patch.json ``` -内嵌图片的增删改通过 HTML 正文自动联动: -- **添加**:在 HTML 中写 ``,自动创建 MIME 部分 -- **删除**:从 HTML 中移除 `` 标签,对应 MIME 部分自动清理 -- **替换**:将 `src` 改为新的本地路径,旧 MIME 部分自动移除、新部分自动创建 - -> **高级用法:** 需要精确控制 CID 命名时,仍可使用 `add_inline` 手动添加 MIME 部分,并在 HTML 中用 `` 引用。 - ### 使用 patch-file 进行高级编辑 ```bash From 5051e259ba371df2e80a9119ffda186b6b123e63 Mon Sep 17 00:00:00 2001 From: "fengzhihao.infeng" Date: Wed, 1 Apr 2026 22:30:06 +0800 Subject: [PATCH 3/3] Reapply "fix(mail): clarify that file path flags only accept relative paths (#141)" This reverts commit d465e085b1c88d3ebcee3548344256b1113c1533. --- shortcuts/mail/mail_draft_create.go | 4 ++-- shortcuts/mail/mail_draft_edit.go | 7 ++++--- shortcuts/mail/mail_forward.go | 4 ++-- shortcuts/mail/mail_reply.go | 4 ++-- shortcuts/mail/mail_reply_all.go | 4 ++-- shortcuts/mail/mail_send.go | 4 ++-- .../references/lark-mail-draft-create.md | 4 ++-- .../references/lark-mail-draft-edit.md | 21 ++++++++++--------- .../lark-mail/references/lark-mail-forward.md | 8 +++---- .../references/lark-mail-reply-all.md | 4 ++-- .../lark-mail/references/lark-mail-reply.md | 8 +++---- skills/lark-mail/references/lark-mail-send.md | 8 +++---- 12 files changed, 41 insertions(+), 39 deletions(-) diff --git a/shortcuts/mail/mail_draft_create.go b/shortcuts/mail/mail_draft_create.go index 8b932795a..d6f706903 100644 --- a/shortcuts/mail/mail_draft_create.go +++ b/shortcuts/mail/mail_draft_create.go @@ -43,8 +43,8 @@ var MailDraftCreate = common.Shortcut{ {Name: "cc", Desc: "Optional. Full Cc recipient list. Separate multiple addresses with commas. Display-name format is supported."}, {Name: "bcc", Desc: "Optional. Full Bcc recipient list. Separate multiple addresses with commas. Display-name format is supported."}, {Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring HTML auto-detection. Cannot be used with --inline."}, - {Name: "attach", Desc: "Optional. Regular attachment file paths. Separate multiple paths with commas. Each path must point to a readable local file."}, - {Name: "inline", Desc: "Optional. Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. 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: "attach", Desc: "Optional. Regular attachment file paths (relative path only). Separate multiple paths with commas. Each path must point to a readable local file."}, + {Name: "inline", Desc: "Optional. 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\"."}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { input, err := parseDraftCreateInput(runtime) diff --git a/shortcuts/mail/mail_draft_edit.go b/shortcuts/mail/mail_draft_edit.go index 8f7868441..99061b8bc 100644 --- a/shortcuts/mail/mail_draft_edit.go +++ b/shortcuts/mail/mail_draft_edit.go @@ -32,7 +32,7 @@ var MailDraftEdit = common.Shortcut{ {Name: "set-to", Desc: "Replace the entire To recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."}, {Name: "set-cc", Desc: "Replace the entire Cc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."}, {Name: "set-bcc", Desc: "Replace the entire Bcc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."}, - {Name: "patch-file", Desc: "Edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. All body edits MUST go through --patch-file. Two body ops: set_body (full replacement including quote) and set_reply_body (replaces only user-authored content, auto-preserves quote block). Run --inspect first to check has_quoted_content, then --print-patch-template for the JSON structure."}, + {Name: "patch-file", Desc: "Edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. All body edits MUST go through --patch-file. Two body ops: set_body (full replacement including quote) and set_reply_body (replaces only user-authored content, auto-preserves quote block). Run --inspect first to check has_quoted_content, then --print-patch-template for the JSON structure. Relative path only."}, {Name: "print-patch-template", Type: "bool", Desc: "Print the JSON template and supported operations for the --patch-file flag. Recommended first step before generating a patch file. No draft read or write is performed."}, {Name: "inspect", Type: "bool", Desc: "Inspect the draft without modifying it. Returns the draft projection including subject, recipients, body summary, has_quoted_content (whether the draft contains a reply/forward quote block), attachments_summary (with part_id and cid for each attachment), and inline_summary. Run this BEFORE editing body to check has_quoted_content: if true, use set_reply_body in --patch-file to preserve the quote; if false, use set_body."}, }, @@ -307,7 +307,7 @@ func buildDraftEditPatchTemplate() map[string]interface{} { {"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; the quote block is re-appended automatically)"}}, {"op": "set_header", "shape": map[string]interface{}{"name": "string", "value": "string"}}, {"op": "remove_header", "shape": map[string]interface{}{"name": "string"}}, - {"op": "add_attachment", "shape": map[string]interface{}{"path": "string"}}, + {"op": "add_attachment", "shape": map[string]interface{}{"path": "string(relative path)"}}, {"op": "remove_attachment", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, {"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}}, {"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}}, @@ -340,7 +340,7 @@ func buildDraftEditPatchTemplate() map[string]interface{} { { "group": "attachments_and_inline", "ops": []map[string]interface{}{ - {"op": "add_attachment", "shape": map[string]interface{}{"path": "string"}}, + {"op": "add_attachment", "shape": map[string]interface{}{"path": "string(relative path)"}}, {"op": "remove_attachment", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, {"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}}, {"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}}, @@ -360,6 +360,7 @@ func buildDraftEditPatchTemplate() map[string]interface{} { }, "notes": []string{ "`ops` is executed in order", + "all file paths (--patch-file and `path` fields in ops) must be relative — no absolute paths or .. traversal", "all body edits MUST go through --patch-file; there is no --set-body flag", "`set_body` replaces the ENTIRE body including any reply/forward quote block; when the draft has both text/plain and text/html, it updates the HTML body and regenerates the plain-text summary, so the input should be HTML", "`set_reply_body` replaces only the user-authored portion of the body and automatically re-appends the trailing reply/forward quote block (generated by +reply or +forward); the value you pass should contain ONLY the new user-authored content WITHOUT the quote block — the quote block will be re-inserted automatically; if the user wants to modify content INSIDE the quote block, use `set_body` instead for full replacement; if the draft has no quote block, it behaves identically to `set_body`", diff --git a/shortcuts/mail/mail_forward.go b/shortcuts/mail/mail_forward.go index 5b767e677..905bc8afc 100644 --- a/shortcuts/mail/mail_forward.go +++ b/shortcuts/mail/mail_forward.go @@ -30,8 +30,8 @@ var MailForward = common.Shortcut{ {Name: "cc", Desc: "CC email address(es), comma-separated"}, {Name: "bcc", Desc: "BCC email address(es), comma-separated"}, {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)"}, - {Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. 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: "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."}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { diff --git a/shortcuts/mail/mail_reply.go b/shortcuts/mail/mail_reply.go index 638665e2d..b89bc5d69 100644 --- a/shortcuts/mail/mail_reply.go +++ b/shortcuts/mail/mail_reply.go @@ -28,8 +28,8 @@ var MailReply = common.Shortcut{ {Name: "cc", Desc: "Additional CC email address(es), comma-separated"}, {Name: "bcc", Desc: "BCC email address(es), comma-separated"}, {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"}, - {Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. 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: "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."}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { diff --git a/shortcuts/mail/mail_reply_all.go b/shortcuts/mail/mail_reply_all.go index 91d600bf0..6b82365ef 100644 --- a/shortcuts/mail/mail_reply_all.go +++ b/shortcuts/mail/mail_reply_all.go @@ -29,8 +29,8 @@ var MailReplyAll = common.Shortcut{ {Name: "bcc", Desc: "BCC email address(es), comma-separated"}, {Name: "remove", Desc: "Address(es) to exclude from the outgoing reply, comma-separated"}, {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"}, - {Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. 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: "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."}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { diff --git a/shortcuts/mail/mail_send.go b/shortcuts/mail/mail_send.go index 71c429df1..43b63826b 100644 --- a/shortcuts/mail/mail_send.go +++ b/shortcuts/mail/mail_send.go @@ -28,8 +28,8 @@ var MailSend = common.Shortcut{ {Name: "cc", Desc: "CC email address(es), comma-separated"}, {Name: "bcc", Desc: "BCC email address(es), comma-separated"}, {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"}, - {Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. 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: "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."}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { diff --git a/skills/lark-mail/references/lark-mail-draft-create.md b/skills/lark-mail/references/lark-mail-draft-create.md index 86ea2841e..7a33cd7cb 100644 --- a/skills/lark-mail/references/lark-mail-draft-create.md +++ b/skills/lark-mail/references/lark-mail-draft-create.md @@ -48,8 +48,8 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te | `--cc ` | 否 | 完整抄送列表,多个用逗号分隔 | | `--bcc ` | 否 | 完整密送列表,多个用逗号分隔 | | `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用 | -| `--attach ` | 否 | 普通附件文件路径,多个用逗号分隔 | -| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | +| `--attach ` | 否 | 普通附件文件路径,多个用逗号分隔。相对路径 | +| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | | `--format ` | 否 | 输出格式:`json`(默认)/ `pretty` / `table` / `ndjson` / `csv` | | `--dry-run` | 否 | 仅打印请求,不执行 | diff --git a/skills/lark-mail/references/lark-mail-draft-edit.md b/skills/lark-mail/references/lark-mail-draft-edit.md index 31d2435f1..6831700ca 100644 --- a/skills/lark-mail/references/lark-mail-draft-edit.md +++ b/skills/lark-mail/references/lark-mail-draft-edit.md @@ -69,7 +69,7 @@ lark-cli mail +draft-edit --draft-id --set-subject '测试' --dry-run | `--set-to ` | 否 | 用此处提供的地址替换整个 To 收件人列表 | | `--set-cc ` | 否 | 用此处提供的地址替换整个 Cc 抄送列表 | | `--set-bcc ` | 否 | 用此处提供的地址替换整个 Bcc 密送列表 | -| `--patch-file ` | 否 | 所有正文编辑、增量收件人编辑、邮件头编辑、附件变更和内嵌图片变更的入口。先运行 `--print-patch-template` 查看 JSON 结构 | +| `--patch-file ` | 否 | 所有正文编辑、增量收件人编辑、邮件头编辑、附件变更和内嵌图片变更的入口。相对路径。先运行 `--print-patch-template` 查看 JSON 结构 | | `--print-patch-template` | 否 | 打印 `--patch-file` 的 JSON 模板和支持的操作。建议在生成补丁文件前先运行此命令。不会读取或写入草稿 | | `--inspect` | 否 | 查看草稿但不修改。返回包含 `has_quoted_content`(是否有引用区)、`attachments_summary`(含每个附件的 `part_id`、`cid`、`filename`)和 `inline_summary` 的草稿投影 | | `--format ` | 否 | 输出格式:`json`(默认)/ `pretty` / `table` / `ndjson` / `csv` | @@ -222,6 +222,7 @@ lark-cli mail +draft-edit --draft-id --inspect - `ops` 按顺序执行 - `target` 接受 `part_id` 或 `cid`;优先级:`part_id` > `cid` +- **所有文件路径(`--patch-file` 及 ops 中的 `path`)必须为相对路径** - **正文编辑没有 flag,必须通过 `--patch-file`** - **`set_body` 是完整替换** — 它替换整个正文内容(包括引用区) - **`set_reply_body` 仅替换引用区前面的用户撰写部分** — 引用区自动重新拼接;value 只传用户撰写内容,不要包含引用区;如果用户要修改引用区内容,用 `set_body` 全量覆盖 @@ -250,10 +251,10 @@ lark-cli mail +draft-edit --draft-id --inspect lark-cli mail +draft-edit --draft-id --inspect # 2. 编辑草稿(元数据用 flag,正文用 patch-file) -cat > /tmp/patch.json << 'EOF' +cat > ./patch.json << 'EOF' { "ops": [{ "op": "set_body", "value": "

更新后的内容

" }] } EOF -lark-cli mail +draft-edit --draft-id --set-subject '最终版本' --patch-file /tmp/patch.json +lark-cli mail +draft-edit --draft-id --set-subject '最终版本' --patch-file ./patch.json # 3. 发送草稿 lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":""}' @@ -271,10 +272,10 @@ lark-cli mail +draft-edit --draft-id --inspect # body_html_summary: "
原有回复内容
..." # 2. 使用 set_reply_body 编辑正文(value 只传用户撰写内容,不含引用区) -cat > /tmp/patch.json << 'EOF' +cat > ./patch.json << 'EOF' { "ops": [{ "op": "set_reply_body", "value": "

修改后的回复内容

" }] } EOF -lark-cli mail +draft-edit --draft-id --patch-file /tmp/patch.json +lark-cli mail +draft-edit --draft-id --patch-file ./patch.json ``` **注意:** 如果误用 `set_body`,引用区将被覆盖丢失。如果用户明确要去掉引用区或修改引用区内容,则应使用 `set_body`。 @@ -288,7 +289,7 @@ lark-cli mail +draft-edit --draft-id --inspect # [{"part_id":"1.3","filename":"report.pdf","content_type":"application/pdf"}] # 2. 编写补丁文件,使用步骤 1 中获取的 part_id -cat > /tmp/patch.json << 'EOF' +cat > ./patch.json << 'EOF' { "ops": [ { "op": "remove_attachment", "target": { "part_id": "1.3" } } @@ -298,7 +299,7 @@ cat > /tmp/patch.json << 'EOF' EOF # 3. 应用补丁 -lark-cli mail +draft-edit --draft-id --patch-file /tmp/patch.json +lark-cli mail +draft-edit --draft-id --patch-file ./patch.json ``` ### 在正文中插入内嵌图片 @@ -324,7 +325,7 @@ cat > ./patch.json << 'EOF' EOF # 3. 应用补丁 -lark-cli mail +draft-edit --draft-id --patch-file /tmp/patch.json +lark-cli mail +draft-edit --draft-id --patch-file ./patch.json ``` ### 使用 patch-file 进行高级编辑 @@ -334,7 +335,7 @@ lark-cli mail +draft-edit --draft-id --patch-file /tmp/patch.json lark-cli mail +draft-edit --print-patch-template # 2. 编写补丁文件(例如添加一个抄送并移除一个附件) -cat > /tmp/patch.json << 'EOF' +cat > ./patch.json << 'EOF' { "ops": [ { "op": "add_recipient", "field": "cc", "address": "carol@example.com", "name": "Carol" }, @@ -345,7 +346,7 @@ cat > /tmp/patch.json << 'EOF' EOF # 3. 应用补丁 -lark-cli mail +draft-edit --draft-id --patch-file /tmp/patch.json +lark-cli mail +draft-edit --draft-id --patch-file ./patch.json ``` ## 相关命令 diff --git a/skills/lark-mail/references/lark-mail-forward.md b/skills/lark-mail/references/lark-mail-forward.md index 42cd97173..81fbc2c60 100644 --- a/skills/lark-mail/references/lark-mail-forward.md +++ b/skills/lark-mail/references/lark-mail-forward.md @@ -63,8 +63,8 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run | `--cc ` | 否 | 抄送邮箱,多个用逗号分隔 | | `--bcc ` | 否 | 密送邮箱,多个用逗号分隔 | | `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 | -| `--attach ` | 否 | 附件文件路径,多个用逗号分隔(追加在原邮件附件之后) | -| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | +| `--attach ` | 否 | 附件文件路径,多个用逗号分隔,追加在原邮件附件之后。相对路径 | +| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | | `--confirm-send` | 否 | 确认发送转发(默认只保存草稿)。仅在用户明确确认后使用 | | `--dry-run` | 否 | 仅打印请求,不执行 | @@ -157,10 +157,10 @@ lark-cli mail user_mailbox.messages batch_modify_message --params '{"user_mailbo ```bash # 编辑转发草稿正文(自动保留引用区) -cat > /tmp/patch.json << 'EOF' +cat > ./patch.json << 'EOF' { "ops": [{ "op": "set_reply_body", "value": "

修改后的转发附言

" }] } EOF -lark-cli mail +draft-edit --draft-id --patch-file /tmp/patch.json +lark-cli mail +draft-edit --draft-id --patch-file ./patch.json ``` 如果用户要修改引用区内容或去掉引用区,则使用 `set_body` 全量替换。 diff --git a/skills/lark-mail/references/lark-mail-reply-all.md b/skills/lark-mail/references/lark-mail-reply-all.md index 576fab3fa..c8a7f6020 100644 --- a/skills/lark-mail/references/lark-mail-reply-all.md +++ b/skills/lark-mail/references/lark-mail-reply-all.md @@ -67,8 +67,8 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run | `--bcc ` | 否 | 密送邮箱,多个用逗号分隔 | | `--remove ` | 否 | 从自动聚合结果中排除的邮箱,多个用逗号分隔 | | `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 | -| `--attach ` | 否 | 附件文件路径,多个用逗号分隔 | -| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | +| `--attach ` | 否 | 附件文件路径,多个用逗号分隔。相对路径 | +| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | | `--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 baa513067..3a23d365a 100644 --- a/skills/lark-mail/references/lark-mail-reply.md +++ b/skills/lark-mail/references/lark-mail-reply.md @@ -70,8 +70,8 @@ lark-cli mail +reply --message-id <邮件ID> --body '

测试

' --dry-run | `--cc ` | 否 | 抄送邮箱,多个用逗号分隔 | | `--bcc ` | 否 | 密送邮箱,多个用逗号分隔 | | `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 | -| `--attach ` | 否 | 附件文件路径,多个用逗号分隔 | -| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | +| `--attach ` | 否 | 附件文件路径,多个用逗号分隔。相对路径 | +| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | | `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 | | `--dry-run` | 否 | 仅打印请求,不执行 | @@ -162,10 +162,10 @@ lark-cli mail user_mailbox.messages batch_modify_message --params '{"user_mailbo ```bash # 编辑回复草稿正文(自动保留引用区) -cat > /tmp/patch.json << 'EOF' +cat > ./patch.json << 'EOF' { "ops": [{ "op": "set_reply_body", "value": "

修改后的回复内容

" }] } EOF -lark-cli mail +draft-edit --draft-id --patch-file /tmp/patch.json +lark-cli mail +draft-edit --draft-id --patch-file ./patch.json ``` 如果用户要修改引用区内容或去掉引用区,则使用 `set_body` 全量替换。 diff --git a/skills/lark-mail/references/lark-mail-send.md b/skills/lark-mail/references/lark-mail-send.md index 092e82924..dd196c129 100644 --- a/skills/lark-mail/references/lark-mail-send.md +++ b/skills/lark-mail/references/lark-mail-send.md @@ -67,8 +67,8 @@ lark-cli mail +send --to alice@example.com --subject '测试' --body '

test

` | 否 | 抄送邮箱,多个用逗号分隔 | | `--bcc ` | 否 | 密送邮箱,多个用逗号分隔 | | `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用 | -| `--attach ` | 否 | 附件文件路径,多个用逗号分隔 | -| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid` 和 `file_path`。CID 为唯一标识符,可使用随机十六进制字符串(如 `a1b2c3d4e5f6a7b8c9d0`)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用 | +| `--attach ` | 否 | 附件文件路径,多个用逗号分隔。相对路径 | +| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid` 和 `file_path`(相对路径)。CID 为唯一标识符,可使用随机十六进制字符串(如 `a1b2c3d4e5f6a7b8c9d0`)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用 | | `--confirm-send` | 否 | 确认发送邮件(默认只保存草稿)。仅在用户明确确认收件人和内容后使用 | | `--dry-run` | 否 | 仅打印请求,不执行 | @@ -131,8 +131,8 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me ## 实现说明 - 使用 EML 构建器生成完整 MIME 邮件并 base64url 编码后发送。 -- `--attach` 作为普通附件添加。 -- `--inline` 接受 JSON 数组,每项需提供 `cid`(唯一标识符,可用随机十六进制字符串)和 `file_path`,作为 inline part 嵌入邮件。 +- `--attach` 作为普通附件添加。相对路径。 +- `--inline` 接受 JSON 数组,每项需提供 `cid`(唯一标识符,可用随机十六进制字符串)和 `file_path`(相对路径),作为 inline part 嵌入邮件。 ## 相关命令