From a016578c33eac0205c3488614f8e67fd4fbff929 Mon Sep 17 00:00:00 2001 From: feng zhi hao Date: Wed, 1 Apr 2026 15:47:20 +0800 Subject: [PATCH 1/9] feat(mail): auto-resolve local image paths in draft body HTML (#81) (#139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mail): auto-resolve local image paths in draft body HTML (#81) Allow in set_body/set_reply_body HTML. Local file paths are automatically resolved into inline MIME parts with generated CIDs, eliminating the need to manually pair add_inline with set_body. Removing or replacing an tag in the body automatically cleans up or replaces the corresponding MIME inline part. - Add postProcessInlineImages to unify resolve, validate, and orphan cleanup into a single post-processing step - Extract loadAndAttachInline shared helper to deduplicate addInline and resolveLocalImgSrc logic - Cache resolved paths so the same file is only attached once - Use whitelist URI scheme detection instead of blacklist - Remove dead validateInlineCIDAfterApply and validateOrphanedInlineCIDAfterApply functions Closes #81 * fix(mail): harden inline image CID handling 1. Fix imgSrcRegexp to skip attribute names like data-src/x-src that contain "src" as a suffix — only match the real src attribute. 2. Sanitize cidFromFileName to replace whitespace with hyphens, producing RFC-safe CID tokens (e.g. "my logo.png" → "my-logo"). 3. Add CID validation in newInlinePart to reject spaces, tabs, angle brackets, and parentheses — fail fast instead of silently producing broken inline images in the sent email. * refactor(mail): use UUID for auto-generated inline CIDs Replace filename-derived CID generation (cidFromFileName + uniqueCID) with UUID-based generation. UUIDs contain only [0-9a-f-] characters, eliminating all RFC compliance risks from special characters, Unicode, or filename collisions. Same-file deduplication via pathToCID cache is preserved — multiple tags referencing the same file still share one MIME part and one CID. * fix(mail): avoid panic in generateCID by using uuid.NewRandom uuid.New() calls Must(NewRandom()) which panics if the random source fails. Replace with uuid.NewRandom() and propagate the error through resolveLocalImgSrc, so the CLI returns a clear error instead of crashing in extreme environments. * fix(mail): restore quote block hint in set_reply_body template description The auto-resolve PR accidentally dropped "the quote block is re-appended automatically" from the set_reply_body shape description. Restore it alongside the new local-path support note. * fix(mail): add orphan invariant comment and expand regex test coverage - Add comment in postProcessInlineImages explaining that partially attached inline parts on error are cleaned up by the next Apply. - Add regex test cases: single-quoted src, multiple spaces before src, and newline before src. * fix(mail): use consistent inline predicate and safer HTML part lookup 1. removeOrphanedInlineParts: change condition from ContentDisposition=="inline" && ContentID!="" to isInlinePart(child) && ContentID!="", matching the predicate used elsewhere — parts with only a ContentID (no Content-Disposition) are now correctly cleaned up. 2. postProcessInlineImages: use findPrimaryBodyPart instead of findPart(snapshot.Body, PrimaryHTMLPartID) to avoid stale PartID after ops restructure the MIME tree. * fix(mail): revert orphan cleanup to ContentDisposition check to protect HTML body The previous change (d3d1982) broadened the orphan cleanup predicate to isInlinePart(), which treats any part with a ContentID as inline. This deletes the primary HTML body when it carries a Content-ID header (valid in multipart/related), even on metadata-only edits like set_subject. Revert to the original ContentDisposition=="inline" && ContentID!="" condition — only parts explicitly marked as inline attachments are candidates for orphan removal. Add regression test covering multipart/related with a Content-ID-bearing HTML body. --- 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 | 15 +- .../references/lark-mail-draft-edit.md | 28 +- 5 files changed, 969 insertions(+), 77 deletions(-) create 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 3fc6d3dd..e2c57024 100644 --- a/shortcuts/mail/draft/patch.go +++ b/shortcuts/mail/draft/patch.go @@ -8,12 +8,18 @@ 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, @@ -33,13 +39,10 @@ func Apply(snapshot *DraftSnapshot, patch Patch) error { return err } } - if err := refreshSnapshot(snapshot); err != nil { - return err - } - if err := validateInlineCIDAfterApply(snapshot); err != nil { + if err := postProcessInlineImages(snapshot); err != nil { return err } - return validateOrphanedInlineCIDAfterApply(snapshot) + return refreshSnapshot(snapshot) } func applyOp(snapshot *DraftSnapshot, op PatchOp, options PatchOptions) error { @@ -523,21 +526,25 @@ func addAttachment(snapshot *DraftSnapshot, path string) error { return nil } -func addInline(snapshot *DraftSnapshot, path, cid, fileName, contentType string) error { +// 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) { safePath, err := validate.SafeInputPath(path) if err != nil { - return fmt.Errorf("inline image %q: %w", path, err) + return nil, fmt.Errorf("inline image %q: %w", path, err) } info, err := os.Stat(safePath) if err != nil { - return err + return nil, fmt.Errorf("inline image %q: %w", path, err) } if err := checkSnapshotAttachmentLimit(snapshot, info.Size(), nil); err != nil { - return err + return nil, err } content, err := os.ReadFile(safePath) if err != nil { - return err + return nil, fmt.Errorf("inline image %q: %w", path, err) } name := fileName if strings.TrimSpace(name) == "" { @@ -545,23 +552,30 @@ func addInline(snapshot *DraftSnapshot, path, cid, fileName, contentType string) } detectedCT, err := filecheck.CheckInlineImageFormat(name, content) if err != nil { - return err + return nil, fmt.Errorf("inline image %q: %w", path, err) } - inline, err := newInlinePart(path, content, cid, fileName, detectedCT) + inline, err := newInlinePart(safePath, content, cid, name, detectedCT) if err != nil { - return err + 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 + 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) + } } container.Children = append(container.Children, inline) container.Dirty = true - return nil + return container, nil +} + +func addInline(snapshot *DraftSnapshot, path, cid, fileName, contentType string) error { + _, err := loadAndAttachInline(snapshot, path, cid, fileName, nil) + return err } func replaceInline(snapshot *DraftSnapshot, partID, path, cid, fileName, contentType string) error { @@ -762,6 +776,9 @@ 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 } @@ -857,59 +874,152 @@ func removeHeader(headers *[]Header, name string) { *headers = next } -// 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 +// 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 } - refs := extractCIDRefs(string(htmlPart.Body)) - if len(refs) == 0 { - return nil + if strings.HasPrefix(trimmed, "//") { + return false } - cids := make(map[string]bool) - for _, part := range flattenParts(snapshot.Body) { - if part == nil || part.ContentID == "" { + 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) + } + 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 + } + + 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) { continue } - cids[strings.ToLower(part.ContentID)] = true + + 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:] } - for _, ref := range refs { - if !cids[strings.ToLower(ref)] { - return fmt.Errorf("html body references missing inline cid %q", ref) + + 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 } - return nil + 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 + } + } + kept = append(kept, child) + } + root.Children = kept } -// 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) +// 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") if htmlPart == nil { return nil } - refs := extractCIDRefs(string(htmlPart.Body)) + + 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) refSet := make(map[string]bool, len(refs)) for _, ref := range refs { refSet[strings.ToLower(ref)] = true } - var orphaned []string + + cidParts := make(map[string]bool) for _, part := range flattenParts(snapshot.Body) { if part == nil || part.ContentID == "" { continue } - if !refSet[strings.ToLower(part.ContentID)] { - orphaned = append(orphaned, part.ContentID) - } + cidParts[strings.ToLower(part.ContentID)] = true } - 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) + + for _, ref := range refs { + if !cidParts[strings.ToLower(ref)] { + return fmt.Errorf("html body references missing inline cid %q", ref) + } } + + removeOrphanedInlineParts(snapshot.Body, refSet) return nil } diff --git a/shortcuts/mail/draft/patch_inline_resolve_test.go b/shortcuts/mail/draft/patch_inline_resolve_test.go new file mode 100644 index 00000000..7c43886a --- /dev/null +++ b/shortcuts/mail/draft/patch_inline_resolve_test.go @@ -0,0 +1,773 @@ +// 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 8572f503..e3c55a9e 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 TestApplySetBodyOrphanedInlineCIDIsRejected(t *testing.T) { +func TestApplySetBodyOrphanedInlineCIDIsAutoRemoved(t *testing.T) { snapshot := mustParseFixtureDraft(t, `Subject: Inline From: Alice To: Bob @@ -480,12 +480,18 @@ Content-Transfer-Encoding: base64 cG5n --rel-- `) - // set_body that drops the existing cid:logo reference → logo becomes orphaned + // set_body that drops the existing cid:logo reference → logo is auto-removed err := Apply(snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "
replaced body without cid reference
"}}, }) - if err == nil || !strings.Contains(err.Error(), "orphaned cids") { - t.Fatalf("expected orphaned cid error, got: %v", err) + 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") + } } } diff --git a/shortcuts/mail/mail_draft_edit.go b/shortcuts/mail/mail_draft_edit.go index 99061b8b..17a38372 100644 --- a/shortcuts/mail/mail_draft_edit.go +++ b/shortcuts/mail/mail_draft_edit.go @@ -303,13 +303,13 @@ 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"}}, - {"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_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_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": "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": "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": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, }, @@ -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"}}, - {"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_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)"}}, }, }, { @@ -342,7 +342,7 @@ func buildDraftEditPatchTemplate() map[string]interface{} { "ops": []map[string]interface{}{ {"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": "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": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, }, @@ -359,12 +359,13 @@ 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 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`", - "`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 6831700c..5cb58d29 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" } ``` -> **重要:`add_inline` 仅添加 MIME 二进制部分,不会在 HTML 正文中插入 `` 标签。** -> 如需图片在邮件正文中可见,**必须**同时使用 `set_body` 或 `set_reply_body` 更新 HTML 正文并加入 `` 标签。参见[在正文中插入内嵌图片](#在正文中插入内嵌图片)的完整流程。 -> 如果忘记添加 `` 引用,该内嵌部分在发送时会变成孤立附件。 +> **推荐方式:** 直接在 `set_body`/`set_reply_body` 的 HTML 中使用 ``(本地文件路径),系统会自动创建 MIME 内嵌部分、生成 CID 并替换为 `cid:` 引用。删除或替换 `` 标签时,对应的 MIME 部分会自动清理。详见[在正文中插入内嵌图片](#在正文中插入内嵌图片)。 +> +> `add_inline` 仅在需要精确控制 CID 命名时使用。使用时仍需在 HTML 正文中加入 `` 引用。 `replace_inline` @@ -304,23 +304,18 @@ lark-cli mail +draft-edit --draft-id --patch-file ./patch.json ### 在正文中插入内嵌图片 -添加内嵌图片需要**两个协同编辑**:(1)通过 `add_inline` 添加 MIME 部分,(2)通过 `set_body` 或 `set_reply_body` 在 HTML 正文中插入 `` 标签。 +直接在 `set_body`/`set_reply_body` 的 HTML 中使用本地文件路径即可。系统会自动创建 MIME 内嵌部分并替换为 `cid:` 引用。 ```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) +# 2. 编写补丁 — 直接使用本地文件路径(注意:回复草稿用 set_reply_body,普通草稿用 set_body) cat > ./patch.json << 'EOF' { "ops": [ - { "op": "set_body", "value": "
原有内容
" }, - { "op": "add_inline", "path": "./new-image.png", "cid": "new-image" } - ], - "options": {} + { "op": "set_body", "value": "
内容
" } + ] } EOF @@ -328,6 +323,13 @@ EOF lark-cli mail +draft-edit --draft-id --patch-file ./patch.json ``` +内嵌图片的增删改通过 HTML 正文自动联动: +- **添加**:在 HTML 中写 ``,自动创建 MIME 部分 +- **删除**:从 HTML 中移除 `` 标签,对应 MIME 部分自动清理 +- **替换**:将 `src` 改为新的本地路径,旧 MIME 部分自动移除、新部分自动创建 + +> **高级用法:** 需要精确控制 CID 命名时,仍可使用 `add_inline` 手动添加 MIME 部分,并在 HTML 中用 `` 引用。 + ### 使用 patch-file 进行高级编辑 ```bash From 1fac59ae9b9ed779f40af1f241b4b06ec2e3f5df Mon Sep 17 00:00:00 2001 From: "fengzhihao.infeng" Date: Thu, 2 Apr 2026 03:19:04 +0800 Subject: [PATCH 2/9] feat(mail): extend auto-resolve local image paths to all draft entry points Extract ResolveLocalImagePaths as exported function from draft/patch.go and apply it uniformly across draft-create, send, reply, reply-all, and forward commands. Add bidirectional CID validation via validateInlineCIDs to ensure HTML references and MIME inline parts stay consistent. Update skill references and add tests for the new inline resolution paths. --- shortcuts/mail/draft/patch.go | 124 ++++++-- .../mail/draft/patch_inline_resolve_test.go | 69 +++++ shortcuts/mail/helpers.go | 39 ++- shortcuts/mail/helpers_test.go | 65 ++++- shortcuts/mail/mail_draft_create.go | 33 ++- shortcuts/mail/mail_draft_create_test.go | 152 ++++++++++ shortcuts/mail/mail_forward.go | 39 ++- shortcuts/mail/mail_reply.go | 31 +- shortcuts/mail/mail_reply_all.go | 31 +- .../mail/mail_reply_forward_inline_test.go | 276 ++++++++++++++++++ shortcuts/mail/mail_send.go | 33 ++- .../references/lark-mail-draft-create.md | 18 +- .../lark-mail/references/lark-mail-forward.md | 8 +- .../references/lark-mail-reply-all.md | 8 +- .../lark-mail/references/lark-mail-reply.md | 8 +- skills/lark-mail/references/lark-mail-send.md | 8 +- 16 files changed, 845 insertions(+), 97 deletions(-) create mode 100644 shortcuts/mail/mail_draft_create_test.go create mode 100644 shortcuts/mail/mail_reply_forward_inline_test.go diff --git a/shortcuts/mail/draft/patch.go b/shortcuts/mail/draft/patch.go index e2c57024..f31bc174 100644 --- a/shortcuts/mail/draft/patch.go +++ b/shortcuts/mail/draft/patch.go @@ -902,18 +902,27 @@ func generateCID() (string, error) { 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) { +// LocalImageRef represents a local image found in an HTML body that needs +// to be embedded as an inline MIME part. +type LocalImageRef struct { + FilePath string // original src value from the HTML + CID string // generated Content-ID +} + +// ResolveLocalImagePaths scans HTML for references, +// validates each path, generates CIDs, and returns the modified HTML with +// cid: URIs plus the list of local image references to embed as inline parts. +// This function handles only the HTML transformation; callers are responsible +// for embedding the actual file data (e.g., via emlbuilder.AddFileInline). +func ResolveLocalImagePaths(html string) (string, []LocalImageRef, error) { matches := imgSrcRegexp.FindAllStringSubmatchIndex(html, -1) if len(matches) == 0 { - return html, nil + return html, nil, nil } - var container *Part // Cache resolved paths so the same file is only attached once. pathToCID := make(map[string]string) + var refs []LocalImageRef // Iterate in reverse so that index offsets remain valid after replacement. for i := len(matches) - 1; i >= 0; i-- { @@ -925,28 +934,44 @@ func resolveLocalImgSrc(snapshot *DraftSnapshot, html string) (string, error) { resolvedPath, err := validate.SafeInputPath(src) if err != nil { - return "", fmt.Errorf("inline image %q: %w", src, err) + return "", nil, 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 + return "", nil, err } pathToCID[resolvedPath] = cid - - container, err = loadAndAttachInline(snapshot, src, cid, fileName, container) - if err != nil { - return "", err - } + refs = append(refs, LocalImageRef{FilePath: src, CID: cid}) } html = html[:srcStart] + "cid:" + cid + html[srcEnd:] } - return html, nil + return html, refs, 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) { + resolved, refs, err := ResolveLocalImagePaths(html) + if err != nil { + return "", err + } + + var container *Part + for _, ref := range refs { + fileName := filepath.Base(ref.FilePath) + container, err = loadAndAttachInline(snapshot, ref.FilePath, ref.CID, fileName, container) + if err != nil { + return "", err + } + } + + return resolved, nil } // removeOrphanedInlineParts removes inline MIME parts whose ContentID @@ -977,10 +1002,51 @@ func removeOrphanedInlineParts(root *Part, referencedCIDs map[string]bool) { root.Children = kept } +// ValidateCIDReferences checks that every cid: reference in the HTML body has +// a matching entry in availableCIDs. Returns an error for the first missing CID. +// Both sides are compared case-insensitively. +func ValidateCIDReferences(html string, availableCIDs []string) error { + refs := extractCIDRefs(html) + if len(refs) == 0 { + return nil + } + cidSet := make(map[string]bool, len(availableCIDs)) + for _, cid := range availableCIDs { + cidSet[strings.ToLower(cid)] = true + } + for _, ref := range refs { + if !cidSet[strings.ToLower(ref)] { + return fmt.Errorf("html body references missing inline cid %q", ref) + } + } + return nil +} + +// FindOrphanedCIDs returns CIDs from addedCIDs that are not referenced in the +// HTML body via . These would appear as unexpected +// attachments when the email is sent. +func FindOrphanedCIDs(html string, addedCIDs []string) []string { + refs := extractCIDRefs(html) + refSet := make(map[string]bool, len(refs)) + for _, ref := range refs { + refSet[strings.ToLower(ref)] = true + } + var orphaned []string + for _, cid := range addedCIDs { + if !refSet[strings.ToLower(cid)] { + orphaned = append(orphaned, cid) + } + } + return orphaned +} + // 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. +// NOTE: The EML builder path has an equivalent function processInlineImagesForEML +// in shortcuts/mail/helpers.go. When adding new validation or processing logic here, +// update processInlineImagesForEML as well (or extract a shared function). func postProcessInlineImages(snapshot *DraftSnapshot) error { htmlPart := findPrimaryBodyPart(snapshot.Body, "text/html") if htmlPart == nil { @@ -988,9 +1054,6 @@ func postProcessInlineImages(snapshot *DraftSnapshot) error { } 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 @@ -1000,26 +1063,23 @@ func postProcessInlineImages(snapshot *DraftSnapshot) error { htmlPart.Dirty = true } - refs := extractCIDRefs(html) - refSet := make(map[string]bool, len(refs)) - for _, ref := range refs { - refSet[strings.ToLower(ref)] = true - } - - cidParts := make(map[string]bool) + // Collect all CIDs present as MIME parts. + var cidParts []string for _, part := range flattenParts(snapshot.Body) { - if part == nil || part.ContentID == "" { - continue + if part != nil && part.ContentID != "" { + cidParts = append(cidParts, part.ContentID) } - 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 err := ValidateCIDReferences(html, cidParts); err != nil { + return err } + refs := extractCIDRefs(html) + refSet := make(map[string]bool, len(refs)) + for _, ref := range refs { + refSet[strings.ToLower(ref)] = true + } removeOrphanedInlineParts(snapshot.Body, refSet) return nil } diff --git a/shortcuts/mail/draft/patch_inline_resolve_test.go b/shortcuts/mail/draft/patch_inline_resolve_test.go index 7c43886a..2955a329 100644 --- a/shortcuts/mail/draft/patch_inline_resolve_test.go +++ b/shortcuts/mail/draft/patch_inline_resolve_test.go @@ -751,6 +751,75 @@ func TestImgSrcRegexpSkipsDataSrc(t *testing.T) { } } +// --------------------------------------------------------------------------- +// ResolveLocalImagePaths — exported function for EML build paths +// --------------------------------------------------------------------------- + +func TestResolveLocalImagePathsBasic(t *testing.T) { + chdirTemp(t) + os.WriteFile("photo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) + + html := `
Hello
` + resolved, refs, err := ResolveLocalImagePaths(html) + if err != nil { + t.Fatalf("ResolveLocalImagePaths() error = %v", err) + } + if strings.Contains(resolved, "./photo.png") { + t.Fatal("local path should have been replaced") + } + if len(refs) != 1 { + t.Fatalf("expected 1 ref, got %d", len(refs)) + } + if refs[0].FilePath != "./photo.png" { + t.Errorf("expected FilePath ./photo.png, got %q", refs[0].FilePath) + } + if !strings.Contains(resolved, "cid:"+refs[0].CID) { + t.Fatalf("expected resolved HTML to contain cid:%s", refs[0].CID) + } +} + +func TestResolveLocalImagePathsSkipsRemoteURLs(t *testing.T) { + html := `
` + resolved, refs, err := ResolveLocalImagePaths(html) + if err != nil { + t.Fatalf("ResolveLocalImagePaths() error = %v", err) + } + if resolved != html { + t.Fatal("expected unchanged HTML for remote URLs") + } + if len(refs) != 0 { + t.Fatalf("expected 0 refs, got %d", len(refs)) + } +} + +func TestResolveLocalImagePathsDeduplicatesSameFile(t *testing.T) { + chdirTemp(t) + os.WriteFile("icon.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) + + html := `` + _, refs, err := ResolveLocalImagePaths(html) + if err != nil { + t.Fatalf("ResolveLocalImagePaths() error = %v", err) + } + if len(refs) != 1 { + t.Fatalf("same file should produce 1 ref, got %d", len(refs)) + } +} + +func TestResolveLocalImagePathsNoImages(t *testing.T) { + html := "no html images at all" + resolved, refs, err := ResolveLocalImagePaths(html) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resolved != html { + t.Fatal("expected unchanged text") + } + if len(refs) != 0 { + t.Fatalf("expected 0 refs, got %d", len(refs)) + } +} + // --------------------------------------------------------------------------- // newInlinePart — rejects CIDs with spaces or other invalid characters // --------------------------------------------------------------------------- diff --git a/shortcuts/mail/helpers.go b/shortcuts/mail/helpers.go index e054a7dd..00e4406e 100644 --- a/shortcuts/mail/helpers.go +++ b/shortcuts/mail/helpers.go @@ -22,6 +22,7 @@ import ( "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" + draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" "github.com/larksuite/cli/shortcuts/mail/emlbuilder" ) @@ -1763,11 +1764,33 @@ func normalizeInlineCID(cid string) string { return strings.TrimSpace(trimmed) } -func addInlineImagesToBuilder(runtime *common.RuntimeContext, bld emlbuilder.Builder, images []inlineSourcePart) (emlbuilder.Builder, error) { +// validateInlineCIDs checks bidirectional CID consistency between HTML body and +// inline MIME parts — the same checks as postProcessInlineImages in draft-edit. +// 1. Every cid: reference in HTML must have a corresponding inline part (checked +// against userCIDs + extraCIDs combined). +// 2. Every user-provided inline part must be referenced in HTML (orphan check +// against userCIDs only — extraCIDs such as source-message images in +// reply/forward are excluded because quoting may drop some references). +func validateInlineCIDs(html string, userCIDs, extraCIDs []string) error { + allCIDs := append(append([]string{}, userCIDs...), extraCIDs...) + if err := draftpkg.ValidateCIDReferences(html, allCIDs); err != nil { + return err + } + if len(userCIDs) > 0 { + orphaned := draftpkg.FindOrphanedCIDs(html, userCIDs) + if len(orphaned) > 0 { + return fmt.Errorf("inline images with cids %v are not referenced by any in the HTML body and will appear as unexpected attachments; remove unused --inline entries or add matching tags", orphaned) + } + } + return nil +} + +func addInlineImagesToBuilder(runtime *common.RuntimeContext, bld emlbuilder.Builder, images []inlineSourcePart) (emlbuilder.Builder, []string, error) { + var cids []string for _, img := range images { content, err := downloadAttachmentContent(runtime, img.DownloadURL) if err != nil { - return bld, fmt.Errorf("failed to download inline resource %s: %w", img.Filename, err) + return bld, nil, fmt.Errorf("failed to download inline resource %s: %w", img.Filename, err) } cid := normalizeInlineCID(img.CID) if cid == "" { @@ -1778,8 +1801,9 @@ func addInlineImagesToBuilder(runtime *common.RuntimeContext, bld emlbuilder.Bui contentType = "application/octet-stream" } bld = bld.AddInline(content, contentType, img.Filename, cid) + cids = append(cids, cid) } - return bld, nil + return bld, cids, nil } // InlineSpec represents one inline image entry from the --inline JSON array. @@ -1908,13 +1932,14 @@ func validateComposeInlineAndAttachments(attachFlag, inlineFlag string, plainTex return fmt.Errorf("--inline requires an HTML body (the provided body appears to be plain text; add HTML tags or remove --inline)") } } + // Validate explicitly provided files (--attach + --inline) early so that + // dry-run and reply/forward can catch local errors before Execute. + // Auto-resolved local images are only known at Execute time, so Execute + // performs a second, complete size check that includes them. inlineSpecs, err := parseInlineSpecs(inlineFlag) if err != nil { return err } allFiles := append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...) - if err := checkAttachmentSizeLimit(allFiles, 0); err != nil { - return err - } - return nil + return checkAttachmentSizeLimit(allFiles, 0) } diff --git a/shortcuts/mail/helpers_test.go b/shortcuts/mail/helpers_test.go index 3b5d3159..13ab7f14 100644 --- a/shortcuts/mail/helpers_test.go +++ b/shortcuts/mail/helpers_test.go @@ -614,6 +614,67 @@ func TestCheckAttachmentSizeLimit_WithFiles(t *testing.T) { } } +// --------------------------------------------------------------------------- +// validateInlineCIDs — bidirectional CID consistency +// --------------------------------------------------------------------------- + +func TestValidateInlineCIDs_UserOrphanError(t *testing.T) { + // User-provided CID not referenced in body → error. + err := validateInlineCIDs(`

no image

`, []string{"orphan-cid"}, nil) + if err == nil { + t.Fatal("expected orphaned CID error") + } + if !strings.Contains(err.Error(), "orphan-cid") { + t.Fatalf("expected error mentioning orphan-cid, got: %v", err) + } +} + +func TestValidateInlineCIDs_SourceOrphanAllowed(t *testing.T) { + // Source-message CID not referenced in body → allowed (quoting may drop references). + err := validateInlineCIDs(`

no image

`, nil, []string{"source-unused"}) + if err != nil { + t.Fatalf("source CID orphan should not error, got: %v", err) + } +} + +func TestValidateInlineCIDs_SourceAndUserMixed(t *testing.T) { + // Body references both a source CID and a user CID. + // Source has an extra unreferenced CID — should not error. + html := `

` + err := validateInlineCIDs(html, []string{"user-img"}, []string{"src-used", "src-unused"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateInlineCIDs_MissingRefError(t *testing.T) { + // Body references a CID that nobody provided → error. + html := `

` + err := validateInlineCIDs(html, []string{"exists"}, nil) + if err == nil { + t.Fatal("expected missing CID error") + } + if !strings.Contains(err.Error(), "missing") { + t.Fatalf("expected error mentioning missing, got: %v", err) + } +} + +func TestValidateInlineCIDs_MissingRefSatisfiedBySource(t *testing.T) { + // Body references a CID that only exists in source (extraCIDs) → ok. + html := `

` + err := validateInlineCIDs(html, nil, []string{"from-source"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateInlineCIDs_NoCIDsNoError(t *testing.T) { + err := validateInlineCIDs(`

plain text

`, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + // --------------------------------------------------------------------------- // downloadAttachmentContent — size limit enforcement // --------------------------------------------------------------------------- @@ -678,7 +739,7 @@ func TestAddInlineImagesToBuilder_EmptyCIDSkipped(t *testing.T) { images := []inlineSourcePart{ {ID: "img1", Filename: "logo.png", ContentType: "image/png", CID: "", DownloadURL: srv.URL + "/img1"}, } - _, err := addInlineImagesToBuilder(rt, bld, images) + _, _, err := addInlineImagesToBuilder(rt, bld, images) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -699,7 +760,7 @@ func TestAddInlineImagesToBuilder_Success(t *testing.T) { images := []inlineSourcePart{ {ID: "img1", Filename: "banner.png", ContentType: "image/png", CID: "cid:banner", DownloadURL: srv.URL + "/img1"}, } - result, err := addInlineImagesToBuilder(rt, bld, images) + result, _, err := addInlineImagesToBuilder(rt, bld, images) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/shortcuts/mail/mail_draft_create.go b/shortcuts/mail/mail_draft_create.go index d6f70690..c0dd6373 100644 --- a/shortcuts/mail/mail_draft_create.go +++ b/shortcuts/mail/mail_draft_create.go @@ -148,23 +148,42 @@ func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreate if input.BCC != "" { bld = bld.BCCAddrs(parseNetAddrs(input.BCC)) } + inlineSpecs, err := parseInlineSpecs(input.Inline) + if err != nil { + return "", output.ErrValidation("%v", err) + } + var autoResolvedPaths []string if input.PlainText { bld = bld.TextBody([]byte(input.Body)) } else if bodyIsHTML(input.Body) { - bld = bld.HTMLBody([]byte(input.Body)) + resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(input.Body) + if resolveErr != nil { + return "", resolveErr + } + bld = bld.HTMLBody([]byte(resolved)) + var allCIDs []string + for _, ref := range refs { + bld = bld.AddFileInline(ref.FilePath, ref.CID) + autoResolvedPaths = append(autoResolvedPaths, ref.FilePath) + allCIDs = append(allCIDs, ref.CID) + } + for _, spec := range inlineSpecs { + bld = bld.AddFileInline(spec.FilePath, spec.CID) + allCIDs = append(allCIDs, spec.CID) + } + if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil { + return "", err + } } else { bld = bld.TextBody([]byte(input.Body)) } - inlineSpecs, err := parseInlineSpecs(input.Inline) - if err != nil { - return "", output.ErrValidation("%v", err) + allFilePaths := append(append(splitByComma(input.Attach), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...) + if err := checkAttachmentSizeLimit(allFilePaths, 0); err != nil { + return "", err } for _, path := range splitByComma(input.Attach) { bld = bld.AddFileAttachment(path) } - for _, spec := range inlineSpecs { - bld = bld.AddFileInline(spec.FilePath, spec.CID) - } rawEML, err := bld.BuildBase64URL() if err != nil { return "", output.ErrValidation("build EML failed: %v", err) diff --git a/shortcuts/mail/mail_draft_create_test.go b/shortcuts/mail/mail_draft_create_test.go new file mode 100644 index 00000000..2f907a1d --- /dev/null +++ b/shortcuts/mail/mail_draft_create_test.go @@ -0,0 +1,152 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "os" + "strings" + "testing" +) + +func TestBuildRawEMLForDraftCreate_ResolvesLocalImages(t *testing.T) { + chdirTemp(t) + os.WriteFile("test_image.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) + + input := draftCreateInput{ + From: "sender@example.com", + Subject: "local image test", + Body: `

Hello

`, + } + + rawEML, err := buildRawEMLForDraftCreate(nil, input) + if err != nil { + t.Fatalf("buildRawEMLForDraftCreate() error = %v", err) + } + + eml := decodeBase64URL(rawEML) + + if strings.Contains(eml, `src="./test_image.png"`) { + t.Fatal("local image path should have been replaced with cid: reference") + } + if !strings.Contains(eml, "cid:") { + t.Fatal("expected cid: reference in resolved HTML body") + } + if !strings.Contains(eml, "Content-Disposition: inline") { + t.Fatal("expected inline MIME part for the resolved image") + } +} + +func TestBuildRawEMLForDraftCreate_NoLocalImages(t *testing.T) { + input := draftCreateInput{ + From: "sender@example.com", + Subject: "plain html", + Body: `

Hello world

`, + } + + rawEML, err := buildRawEMLForDraftCreate(nil, input) + if err != nil { + t.Fatalf("buildRawEMLForDraftCreate() error = %v", err) + } + + eml := decodeBase64URL(rawEML) + + if !strings.Contains(eml, "Hello") { + t.Fatal("expected body content in EML") + } + if strings.Contains(eml, "Content-Disposition: inline") { + t.Fatal("no inline parts expected without local images") + } +} + +func TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit(t *testing.T) { + chdirTemp(t) + // Create a 1KB PNG file — small, but enough to push over the limit + // when combined with a near-limit --attach file. + pngHeader := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A} + imgData := make([]byte, 1024) + copy(imgData, pngHeader) + os.WriteFile("photo.png", imgData, 0o644) + + // Create an attach file that's just under the 25MB limit (use .txt — allowed extension). + bigFile := make([]byte, MaxAttachmentBytes-500) + os.WriteFile("big.txt", bigFile, 0o644) + + input := draftCreateInput{ + From: "sender@example.com", + Subject: "size limit test", + Body: `

`, + Attach: "./big.txt", + } + + _, err := buildRawEMLForDraftCreate(nil, input) + if err == nil { + t.Fatal("expected size limit error when auto-resolved image + attachment exceed 25MB") + } + if !strings.Contains(err.Error(), "25 MB") { + t.Fatalf("expected 25 MB limit error, got: %v", err) + } +} + +func TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError(t *testing.T) { + chdirTemp(t) + os.WriteFile("unused.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) + + input := draftCreateInput{ + From: "sender@example.com", + Subject: "orphan test", + Body: `

No image reference here

`, + Inline: `[{"cid":"orphan","file_path":"./unused.png"}]`, + } + + _, err := buildRawEMLForDraftCreate(nil, input) + if err == nil { + t.Fatal("expected error for orphaned --inline CID not referenced in body") + } + if !strings.Contains(err.Error(), "orphan") { + t.Fatalf("expected error mentioning orphan, got: %v", err) + } +} + +func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) { + chdirTemp(t) + os.WriteFile("present.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) + + input := draftCreateInput{ + From: "sender@example.com", + Subject: "missing cid test", + Body: `

`, + Inline: `[{"cid":"present","file_path":"./present.png"}]`, + } + + _, err := buildRawEMLForDraftCreate(nil, input) + if err == nil { + t.Fatal("expected error for missing CID reference") + } + if !strings.Contains(err.Error(), "missing") { + t.Fatalf("expected error mentioning missing, got: %v", err) + } +} + +func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) { + chdirTemp(t) + os.WriteFile("img.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) + + input := draftCreateInput{ + From: "sender@example.com", + Subject: "plain text", + Body: `check text`, + PlainText: true, + } + + rawEML, err := buildRawEMLForDraftCreate(nil, input) + if err != nil { + t.Fatalf("buildRawEMLForDraftCreate() error = %v", err) + } + + eml := decodeBase64URL(rawEML) + + if strings.Contains(eml, "cid:") { + t.Fatal("plain-text mode should not resolve local images") + } +} diff --git a/shortcuts/mail/mail_forward.go b/shortcuts/mail/mail_forward.go index 905bc8af..63142532 100644 --- a/shortcuts/mail/mail_forward.go +++ b/shortcuts/mail/mail_forward.go @@ -121,16 +121,41 @@ var MailForward = common.Shortcut{ if strings.TrimSpace(inlineFlag) != "" && !useHTML { return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML") } + inlineSpecs, err := parseInlineSpecs(inlineFlag) + if err != nil { + return err + } + var autoResolvedPaths []string if useHTML { if err := validateInlineImageURLs(sourceMsg); err != nil { return fmt.Errorf("forward blocked: %w", err) } processedBody := buildBodyDiv(body, bodyIsHTML(body)) - bld = bld.HTMLBody([]byte(processedBody + buildForwardQuoteHTML(&orig))) - bld, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages) + forwardQuote := buildForwardQuoteHTML(&orig) + var srcCIDs []string + bld, srcCIDs, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages) if err != nil { return err } + resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(processedBody) + if resolveErr != nil { + return resolveErr + } + fullHTML := resolved + forwardQuote + bld = bld.HTMLBody([]byte(fullHTML)) + var userCIDs []string + for _, ref := range refs { + bld = bld.AddFileInline(ref.FilePath, ref.CID) + autoResolvedPaths = append(autoResolvedPaths, ref.FilePath) + userCIDs = append(userCIDs, ref.CID) + } + for _, spec := range inlineSpecs { + bld = bld.AddFileInline(spec.FilePath, spec.CID) + userCIDs = append(userCIDs, spec.CID) + } + if err := validateInlineCIDs(fullHTML, userCIDs, srcCIDs); err != nil { + return err + } } else { bld = bld.TextBody([]byte(buildForwardedMessage(&orig, body))) } @@ -169,11 +194,8 @@ var MailForward = common.Shortcut{ } bld = bld.Header("X-Lms-Large-Attachment-Ids", base64.StdEncoding.EncodeToString(idsJSON)) } - inlineSpecs, err := parseInlineSpecs(inlineFlag) - if err != nil { - return err - } - if err := checkAttachmentSizeLimit(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), origAttBytes, len(origAtts)); err != nil { + allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...) + if err := checkAttachmentSizeLimit(allFilePaths, origAttBytes, len(origAtts)); err != nil { return err } for _, att := range origAtts { @@ -182,9 +204,6 @@ var MailForward = common.Shortcut{ for _, path := range splitByComma(attachFlag) { bld = bld.AddFileAttachment(path) } - for _, spec := range inlineSpecs { - bld = bld.AddFileInline(spec.FilePath, spec.CID) - } rawEML, err := bld.BuildBase64URL() if err != nil { return fmt.Errorf("failed to build EML: %w", err) diff --git a/shortcuts/mail/mail_reply.go b/shortcuts/mail/mail_reply.go index b89bc5d6..635746ad 100644 --- a/shortcuts/mail/mail_reply.go +++ b/shortcuts/mail/mail_reply.go @@ -128,24 +128,45 @@ var MailReply = common.Shortcut{ if messageId != "" { bld = bld.LMSReplyToMessageID(messageId) } + var autoResolvedPaths []string if useHTML { if err := validateInlineImageURLs(sourceMsg); err != nil { return fmt.Errorf("HTML reply blocked: %w", err) } - bld = bld.HTMLBody([]byte(bodyStr + quoted)) - bld, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages) + var srcCIDs []string + bld, srcCIDs, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages) if err != nil { return err } + resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(bodyStr) + if resolveErr != nil { + return resolveErr + } + fullHTML := resolved + quoted + bld = bld.HTMLBody([]byte(fullHTML)) + var userCIDs []string + for _, ref := range refs { + bld = bld.AddFileInline(ref.FilePath, ref.CID) + autoResolvedPaths = append(autoResolvedPaths, ref.FilePath) + userCIDs = append(userCIDs, ref.CID) + } + for _, spec := range inlineSpecs { + bld = bld.AddFileInline(spec.FilePath, spec.CID) + userCIDs = append(userCIDs, spec.CID) + } + if err := validateInlineCIDs(fullHTML, userCIDs, srcCIDs); err != nil { + return err + } } else { bld = bld.TextBody([]byte(bodyStr + quoted)) } + allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...) + if err := checkAttachmentSizeLimit(allFilePaths, 0); err != nil { + return err + } for _, path := range splitByComma(attachFlag) { bld = bld.AddFileAttachment(path) } - for _, spec := range inlineSpecs { - bld = bld.AddFileInline(spec.FilePath, spec.CID) - } rawEML, err := bld.BuildBase64URL() if err != nil { return fmt.Errorf("failed to build EML: %w", err) diff --git a/shortcuts/mail/mail_reply_all.go b/shortcuts/mail/mail_reply_all.go index 6b82365e..a567f0c2 100644 --- a/shortcuts/mail/mail_reply_all.go +++ b/shortcuts/mail/mail_reply_all.go @@ -142,24 +142,45 @@ var MailReplyAll = common.Shortcut{ if messageId != "" { bld = bld.LMSReplyToMessageID(messageId) } + var autoResolvedPaths []string if useHTML { if err := validateInlineImageURLs(sourceMsg); err != nil { return fmt.Errorf("HTML reply-all blocked: %w", err) } - bld = bld.HTMLBody([]byte(bodyStr + quoted)) - bld, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages) + var srcCIDs []string + bld, srcCIDs, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages) if err != nil { return err } + resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(bodyStr) + if resolveErr != nil { + return resolveErr + } + fullHTML := resolved + quoted + bld = bld.HTMLBody([]byte(fullHTML)) + var userCIDs []string + for _, ref := range refs { + bld = bld.AddFileInline(ref.FilePath, ref.CID) + autoResolvedPaths = append(autoResolvedPaths, ref.FilePath) + userCIDs = append(userCIDs, ref.CID) + } + for _, spec := range inlineSpecs { + bld = bld.AddFileInline(spec.FilePath, spec.CID) + userCIDs = append(userCIDs, spec.CID) + } + if err := validateInlineCIDs(fullHTML, userCIDs, srcCIDs); err != nil { + return err + } } else { bld = bld.TextBody([]byte(bodyStr + quoted)) } + allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...) + if err := checkAttachmentSizeLimit(allFilePaths, 0); err != nil { + return err + } for _, path := range splitByComma(attachFlag) { bld = bld.AddFileAttachment(path) } - for _, spec := range inlineSpecs { - bld = bld.AddFileInline(spec.FilePath, spec.CID) - } rawEML, err := bld.BuildBase64URL() if err != nil { return fmt.Errorf("failed to build EML: %w", err) diff --git a/shortcuts/mail/mail_reply_forward_inline_test.go b/shortcuts/mail/mail_reply_forward_inline_test.go new file mode 100644 index 00000000..09b332fd --- /dev/null +++ b/shortcuts/mail/mail_reply_forward_inline_test.go @@ -0,0 +1,276 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "encoding/base64" + "os" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +// stubSourceMessageWithInlineImages registers HTTP stubs for a source message. +// +func stubSourceMessageWithInlineImages(reg *httpmock.Registry, bodyHTML string, allImages []map[string]interface{}) { + // Profile + reg.Register(&httpmock.Stub{ + URL: "/user_mailboxes/me/profile", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "primary_email_address": "me@example.com", + }, + }, + }) + + // Message get + atts := allImages + if atts == nil { + atts = []map[string]interface{}{} + } + reg.Register(&httpmock.Stub{ + URL: "/user_mailboxes/me/messages/msg_001", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "message": map[string]interface{}{ + "message_id": "msg_001", + "thread_id": "thread_001", + "smtp_message_id": "", + "subject": "Original Subject", + "head_from": map[string]interface{}{"mail_address": "sender@example.com", "name": "Sender"}, + "to": []map[string]interface{}{{"mail_address": "me@example.com", "name": "Me"}}, + "cc": []interface{}{}, + "bcc": []interface{}{}, + "body_html": base64.URLEncoding.EncodeToString([]byte(bodyHTML)), + "body_plain_text": base64.URLEncoding.EncodeToString([]byte("plain")), + "internal_date": "1704067200000", + "attachments": atts, + }, + }, + }, + }) + + // Download URLs + if len(allImages) > 0 { + downloadURLs := make([]map[string]interface{}, 0, len(allImages)) + for _, img := range allImages { + id, _ := img["id"].(string) + downloadURLs = append(downloadURLs, map[string]interface{}{ + "attachment_id": id, + "download_url": "https://storage.example.com/" + id, + }) + } + reg.Register(&httpmock.Stub{ + URL: "/attachments/download_url", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "download_urls": downloadURLs, + "failed_ids": []interface{}{}, + }, + }, + }) + } + + // Image downloads + pngBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A} + for _, img := range allImages { + id, _ := img["id"].(string) + reg.Register(&httpmock.Stub{ + URL: "https://storage.example.com/" + id, + RawBody: pngBytes, + }) + } + + // Draft create + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/user_mailboxes/me/drafts", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "draft_id": "draft_001", + }, + }, + }) +} + +// --------------------------------------------------------------------------- +// +reply with source inline images +// --------------------------------------------------------------------------- + +func TestReply_SourceInlineImagesPreserved(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + + stubSourceMessageWithInlineImages(reg, + `

Hello

`, + []map[string]interface{}{ + {"id": "img_001", "filename": "banner.png", "is_inline": true, "cid": "banner_001", "content_type": "image/png"}, + }, + ) + + err := runMountedMailShortcut(t, MailReply, []string{ + "+reply", "--message-id", "msg_001", "--body", "

Thanks!

", + }, f, stdout) + if err != nil { + t.Fatalf("reply failed: %v", err) + } + + data := decodeShortcutEnvelopeData(t, stdout) + if data["draft_id"] == nil || data["draft_id"] == "" { + t.Fatal("expected draft_id in output") + } +} + +func TestReply_SourceOrphanCIDNotBlocked(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + + // Source has TWO inline images, but body HTML only references one. + // The unreferenced image should NOT be downloaded or cause an error. + stubSourceMessageWithInlineImages(reg, + `

Hello

`, + []map[string]interface{}{ + {"id": "img_001", "filename": "used.png", "is_inline": true, "cid": "used_001", "content_type": "image/png"}, + {"id": "img_002", "filename": "unused.png", "is_inline": true, "cid": "unused_002", "content_type": "image/png"}, + }, + ) + + err := runMountedMailShortcut(t, MailReply, []string{ + "+reply", "--message-id", "msg_001", "--body", "

Reply

", + }, f, stdout) + if err != nil { + t.Fatalf("reply should succeed even with unreferenced source CID, got: %v", err) + } +} + +func TestReply_WithAutoResolveLocalImage(t *testing.T) { + chdirTemp(t) + os.WriteFile("local.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) + + f, stdout, _, reg := mailShortcutTestFactory(t) + + stubSourceMessageWithInlineImages(reg, + `

Hello

`, + nil, + ) + + err := runMountedMailShortcut(t, MailReply, []string{ + "+reply", "--message-id", "msg_001", + "--body", `

See image:

`, + }, f, stdout) + if err != nil { + t.Fatalf("reply with auto-resolved local image failed: %v", err) + } +} + +// --------------------------------------------------------------------------- +// +reply-all with source inline images +// --------------------------------------------------------------------------- + +func TestReplyAll_SourceOrphanCIDNotBlocked(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + + stubSourceMessageWithInlineImages(reg, + `

Hello

`, + []map[string]interface{}{ + {"id": "img_001", "filename": "used.png", "is_inline": true, "cid": "used_001", "content_type": "image/png"}, + {"id": "img_002", "filename": "orphan.png", "is_inline": true, "cid": "orphan_002", "content_type": "image/png"}, + }, + ) + + // reply-all also needs self-exclusion profile lookup + reg.Register(&httpmock.Stub{ + URL: "/user_mailboxes/me/profile", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "primary_email_address": "me@example.com", + }, + }, + }) + + err := runMountedMailShortcut(t, MailReplyAll, []string{ + "+reply-all", "--message-id", "msg_001", "--body", "

Reply all

", + }, f, stdout) + if err != nil { + t.Fatalf("reply-all should succeed with unreferenced source CID, got: %v", err) + } +} + +// --------------------------------------------------------------------------- +// +forward with source inline images +// --------------------------------------------------------------------------- + +func TestForward_SourceOrphanCIDNotBlocked(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + + stubSourceMessageWithInlineImages(reg, + `

Hello

`, + []map[string]interface{}{ + {"id": "img_001", "filename": "used.png", "is_inline": true, "cid": "used_001", "content_type": "image/png"}, + {"id": "img_002", "filename": "orphan.png", "is_inline": true, "cid": "orphan_002", "content_type": "image/png"}, + }, + ) + + err := runMountedMailShortcut(t, MailForward, []string{ + "+forward", "--message-id", "msg_001", + "--to", "alice@example.com", + "--body", "

FYI

", + }, f, stdout) + if err != nil { + t.Fatalf("forward should succeed with unreferenced source CID, got: %v", err) + } +} + +func TestForward_WithAutoResolveLocalImage(t *testing.T) { + chdirTemp(t) + os.WriteFile("chart.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) + + f, stdout, _, reg := mailShortcutTestFactory(t) + + stubSourceMessageWithInlineImages(reg, + `

Original content

`, + nil, + ) + + err := runMountedMailShortcut(t, MailForward, []string{ + "+forward", "--message-id", "msg_001", + "--to", "alice@example.com", + "--body", `

See chart:

`, + }, f, stdout) + if err != nil { + t.Fatalf("forward with auto-resolved local image failed: %v", err) + } +} + +// --------------------------------------------------------------------------- +// +reply body auto-resolve does NOT scan quoted content +// --------------------------------------------------------------------------- + +func TestReply_QuotedContentNotAutoResolved(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + + // Source message body has a relative — this should NOT be + // auto-resolved because it's in the quoted portion, not the user body. + stubSourceMessageWithInlineImages(reg, + `

See

`, + nil, + ) + + err := runMountedMailShortcut(t, MailReply, []string{ + "+reply", "--message-id", "msg_001", + "--body", "

Got it

", + }, f, stdout) + // Should succeed — the ./should-not-resolve.png in quoted content is + // NOT auto-resolved (file doesn't exist, would fail if scanned). + if err != nil { + if strings.Contains(err.Error(), "should-not-resolve") { + t.Fatalf("auto-resolve incorrectly scanned quoted content: %v", err) + } + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/shortcuts/mail/mail_send.go b/shortcuts/mail/mail_send.go index 43b63826..533915b2 100644 --- a/shortcuts/mail/mail_send.go +++ b/shortcuts/mail/mail_send.go @@ -92,16 +92,37 @@ var MailSend = common.Shortcut{ if bccFlag != "" { bld = bld.BCCAddrs(parseNetAddrs(bccFlag)) } + inlineSpecs, err := parseInlineSpecs(inlineFlag) + if err != nil { + return err + } + var autoResolvedPaths []string if plainText { bld = bld.TextBody([]byte(body)) } else if bodyIsHTML(body) { - bld = bld.HTMLBody([]byte(body)) + resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(body) + if resolveErr != nil { + return resolveErr + } + bld = bld.HTMLBody([]byte(resolved)) + var allCIDs []string + for _, ref := range refs { + bld = bld.AddFileInline(ref.FilePath, ref.CID) + autoResolvedPaths = append(autoResolvedPaths, ref.FilePath) + allCIDs = append(allCIDs, ref.CID) + } + for _, spec := range inlineSpecs { + bld = bld.AddFileInline(spec.FilePath, spec.CID) + allCIDs = append(allCIDs, spec.CID) + } + if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil { + return err + } } else { bld = bld.TextBody([]byte(body)) } - - inlineSpecs, err := parseInlineSpecs(inlineFlag) - if err != nil { + allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...) + if err := checkAttachmentSizeLimit(allFilePaths, 0); err != nil { return err } @@ -109,10 +130,6 @@ var MailSend = common.Shortcut{ bld = bld.AddFileAttachment(path) } - for _, spec := range inlineSpecs { - bld = bld.AddFileInline(spec.FilePath, spec.CID) - } - rawEML, err := bld.BuildBase64URL() if err != nil { return fmt.Errorf("failed to build EML: %w", err) diff --git a/skills/lark-mail/references/lark-mail-draft-create.md b/skills/lark-mail/references/lark-mail-draft-create.md index 7a33cd7c..73c246e2 100644 --- a/skills/lark-mail/references/lark-mail-draft-create.md +++ b/skills/lark-mail/references/lark-mail-draft-create.md @@ -27,8 +27,8 @@ lark-cli mail +draft-create --to alice@example.com --subject '周报' \ # 不带收件人的 HTML 草稿(用户之后可自行添加) lark-cli mail +draft-create --subject '周报' --body '

草稿内容

' -# 带附件和内嵌图片的 HTML 草稿(CID 为唯一标识符,可用随机十六进制字符串) -lark-cli mail +draft-create --to alice@example.com --subject '预览图' --body '' --attach ./report.pdf --inline '[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]' +# 带附件和内嵌图片的 HTML 草稿(推荐:直接用本地路径,自动解析) +lark-cli mail +draft-create --to alice@example.com --subject '预览图' --body '

见附件和图:

' --attach ./report.pdf # 纯文本草稿(仅在内容极简时使用) lark-cli mail +draft-create --to alice@example.com --subject '简短通知' --body '收到,谢谢' @@ -43,13 +43,13 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te |------|------|------| | `--to ` | 否 | 完整收件人列表,多个用逗号分隔。支持 `Alice ` 格式。省略时草稿不带收件人(之后可通过 `+draft-edit` 添加) | | `--subject ` | 是 | 草稿主题 | -| `--body ` | 是 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式 | +| `--body ` | 是 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `` 本地路径自动解析为内嵌图片 | | `--from ` | 否 | 发件人邮箱地址(作为邮箱选择器)。省略时使用当前登录用户的主邮箱地址 | | `--cc ` | 否 | 完整抄送列表,多个用逗号分隔 | | `--bcc ` | 否 | 完整密送列表,多个用逗号分隔 | | `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用 | | `--attach ` | 否 | 普通附件文件路径,多个用逗号分隔。相对路径 | -| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | +| `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | | `--format ` | 否 | 输出格式:`json`(默认)/ `pretty` / `table` / `ndjson` / `csv` | | `--dry-run` | 否 | 仅打印请求,不执行 | @@ -83,8 +83,16 @@ lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_ ### 创建带内嵌图片的 HTML 草稿 +> **推荐方式:** 直接在 `--body` HTML 中使用 ``(本地文件路径),系统会自动创建内嵌 MIME 部分并替换为 `cid:` 引用。 + ```bash -# CID 为唯一标识符,可用随机十六进制字符串 +# 推荐:直接使用本地文件路径,自动解析为内嵌图片 +lark-cli mail +draft-create \ + --to alice@example.com \ + --subject '通讯稿' \ + --body '

你好

' + +# 高级用法:手动指定 CID(CID 为唯一标识符,可用随机十六进制字符串) lark-cli mail +draft-create \ --to alice@example.com \ --subject '通讯稿' \ diff --git a/skills/lark-mail/references/lark-mail-forward.md b/skills/lark-mail/references/lark-mail-forward.md index 81fbc2c6..68e315b7 100644 --- a/skills/lark-mail/references/lark-mail-forward.md +++ b/skills/lark-mail/references/lark-mail-forward.md @@ -39,8 +39,8 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --body '

--to alice@example.com --cc bob@example.com --body '请参考' -# 转发时插入内嵌图片(CID 为唯一标识符,可用随机字符串) -lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --body ' 详见图示。' --inline '[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]' +# 转发时插入内嵌图片(推荐:直接用本地路径,自动解析) +lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --body '

详见图示:

' # 纯文本转发(仅在内容极简时使用) lark-cli mail +forward --message-id <邮件ID> --to alice@example.com @@ -58,13 +58,13 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run |------|------|------| | `--message-id ` | 是 | 被转发的邮件 ID | | `--to ` | 是 | 收件人邮箱,多个用逗号分隔 | -| `--body ` | 否 | 转发时附加的说明文字。推荐使用 HTML 获得富文本排版;也支持纯文本。根据转发正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式 | +| `--body ` | 否 | 转发时附加的说明文字。推荐使用 HTML 获得富文本排版;也支持纯文本。根据转发正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `` 本地路径自动解析为内嵌图片 | | `--from ` | 否 | 发件人邮箱地址(默认读取 user_mailboxes.profile.primary_email_address) | | `--cc ` | 否 | 抄送邮箱,多个用逗号分隔 | | `--bcc ` | 否 | 密送邮箱,多个用逗号分隔 | | `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 | | `--attach ` | 否 | 附件文件路径,多个用逗号分隔,追加在原邮件附件之后。相对路径 | -| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | +| `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | | `--confirm-send` | 否 | 确认发送转发(默认只保存草稿)。仅在用户明确确认后使用 | | `--dry-run` | 否 | 仅打印请求,不执行 | diff --git a/skills/lark-mail/references/lark-mail-reply-all.md b/skills/lark-mail/references/lark-mail-reply-all.md index c8a7f602..f96c3952 100644 --- a/skills/lark-mail/references/lark-mail-reply-all.md +++ b/skills/lark-mail/references/lark-mail-reply-all.md @@ -42,8 +42,8 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '

同步更新

' -- # 从回复名单中排除某些地址(草稿) lark-cli mail +reply-all --message-id <邮件ID> --body '

见上

' --remove bot@example.com,noreply@example.com -# 回复全部时插入内嵌图片(CID 为唯一标识符,可用随机字符串) -lark-cli mail +reply-all --message-id <邮件ID> --body ' 详见图示。' --inline '[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]' +# 回复全部时插入内嵌图片(推荐:直接用本地路径,自动解析) +lark-cli mail +reply-all --message-id <邮件ID> --body '

详见图示:

' # 纯文本回复全部(仅在内容极简时使用) lark-cli mail +reply-all --message-id <邮件ID> --body '收到,已处理。' @@ -60,7 +60,7 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run | 参数 | 必填 | 说明 | |------|------|------| | `--message-id ` | 是 | 被回复的邮件 ID | -| `--body ` | 是 | 回复正文。推荐使用 HTML 获得富文本排版;也支持纯文本。根据回复正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式 | +| `--body ` | 是 | 回复正文。推荐使用 HTML 获得富文本排版;也支持纯文本。根据回复正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `` 本地路径自动解析为内嵌图片 | | `--from ` | 否 | 发件人邮箱地址(默认读取 user_mailboxes.profile.primary_email_address) | | `--to ` | 否 | 额外收件人,多个用逗号分隔(追加到自动聚合结果) | | `--cc ` | 否 | 额外抄送,多个用逗号分隔 | @@ -68,7 +68,7 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run | `--remove ` | 否 | 从自动聚合结果中排除的邮箱,多个用逗号分隔 | | `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 | | `--attach ` | 否 | 附件文件路径,多个用逗号分隔。相对路径 | -| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | +| `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | | `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 | | `--dry-run` | 否 | 仅打印请求,不执行 | diff --git a/skills/lark-mail/references/lark-mail-reply.md b/skills/lark-mail/references/lark-mail-reply.md index 3a23d365..848780c0 100644 --- a/skills/lark-mail/references/lark-mail-reply.md +++ b/skills/lark-mail/references/lark-mail-reply.md @@ -43,8 +43,8 @@ lark-cli mail +reply --message-id <邮件ID> --body '

已收到,稍 # 回复并追加收件人/抄送(保存为草稿) lark-cli mail +reply --message-id <邮件ID> --body '

已处理

' --to lead@example.com --cc colleague@example.com -# 回复时插入内嵌图片(CID 为唯一标识符,可用随机字符串) -lark-cli mail +reply --message-id <邮件ID> --body ' 详见图示。' --inline '[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]' +# 回复时插入内嵌图片(推荐:直接用本地路径,自动解析) +lark-cli mail +reply --message-id <邮件ID> --body '

详见图示:

' # 纯文本回复(仅在内容极简时使用) lark-cli mail +reply --message-id <邮件ID> --body '收到,谢谢!' @@ -64,14 +64,14 @@ lark-cli mail +reply --message-id <邮件ID> --body '

测试

' --dry-run | 参数 | 必填 | 说明 | |------|------|------| | `--message-id ` | 是 | 被回复的邮件 ID | -| `--body ` | 是 | 回复正文。推荐使用 HTML 获得富文本排版;也支持纯文本。根据回复正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式 | +| `--body ` | 是 | 回复正文。推荐使用 HTML 获得富文本排版;也支持纯文本。根据回复正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `` 本地路径自动解析为内嵌图片 | | `--from ` | 否 | 发件人邮箱地址(默认读取 user_mailboxes.profile.primary_email_address) | | `--to ` | 否 | 额外收件人,多个用逗号分隔(追加到原发件人) | | `--cc ` | 否 | 抄送邮箱,多个用逗号分隔 | | `--bcc ` | 否 | 密送邮箱,多个用逗号分隔 | | `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 | | `--attach ` | 否 | 附件文件路径,多个用逗号分隔。相对路径 | -| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `` 引用 | +| `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | | `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 | | `--dry-run` | 否 | 仅打印请求,不执行 | diff --git a/skills/lark-mail/references/lark-mail-send.md b/skills/lark-mail/references/lark-mail-send.md index dd196c12..ff287e10 100644 --- a/skills/lark-mail/references/lark-mail-send.md +++ b/skills/lark-mail/references/lark-mail-send.md @@ -46,8 +46,8 @@ lark-cli mail +send --to alice@example.com --subject '周报' \ # 保存带附件的草稿 lark-cli mail +send --to alice@example.com --subject '请查收' --body '

见附件

' --attach ./report.pdf,./logs.zip -# 保存带内嵌图片的草稿(CID 为唯一标识符,可用随机字符串) -lark-cli mail +send --to alice@example.com --subject '预览图' --body '' --inline '[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]' +# 保存带内嵌图片的草稿(推荐:直接用本地路径,自动解析) +lark-cli mail +send --to alice@example.com --subject '预览图' --body '' # 纯文本邮件(仅在内容极简时使用) lark-cli mail +send --to alice@example.com --subject '确认' --body '收到,谢谢' @@ -62,13 +62,13 @@ lark-cli mail +send --to alice@example.com --subject '测试' --body '

test

` | 是 | 收件人邮箱,多个用逗号分隔 | | `--subject ` | 是 | 邮件主题 | -| `--body ` | 是 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式 | +| `--body ` | 是 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `` 本地路径自动解析为内嵌图片 | | `--from ` | 否 | 发件人邮箱地址(默认读取 user_mailboxes.profile.primary_email_address) | | `--cc ` | 否 | 抄送邮箱,多个用逗号分隔 | | `--bcc ` | 否 | 密送邮箱,多个用逗号分隔 | | `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用 | | `--attach ` | 否 | 附件文件路径,多个用逗号分隔。相对路径 | -| `--inline ` | 否 | 内嵌图片 JSON 数组,每项包含 `cid` 和 `file_path`(相对路径)。CID 为唯一标识符,可使用随机十六进制字符串(如 `a1b2c3d4e5f6a7b8c9d0`)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用 | +| `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | | `--confirm-send` | 否 | 确认发送邮件(默认只保存草稿)。仅在用户明确确认收件人和内容后使用 | | `--dry-run` | 否 | 仅打印请求,不执行 | From 610fb515796b13880362a4001ee76a17e6c0f590 Mon Sep 17 00:00:00 2001 From: "fengzhihao.infeng" Date: Thu, 2 Apr 2026 12:32:48 +0800 Subject: [PATCH 3/9] fix(mail): fix gofmt formatting and clarify relative path in docs Fix gofmt formatting issues in patch.go (missing blank comment line) and mail_reply_forward_inline_test.go (extra blank comment line). Update all skill reference docs to say "relative path" instead of "local path" since absolute paths are rejected by design. --- shortcuts/mail/draft/patch.go | 1 + shortcuts/mail/mail_reply_forward_inline_test.go | 1 - skills/lark-mail/references/lark-mail-draft-create.md | 8 ++++---- skills/lark-mail/references/lark-mail-draft-edit.md | 8 ++++---- skills/lark-mail/references/lark-mail-forward.md | 4 ++-- skills/lark-mail/references/lark-mail-reply-all.md | 4 ++-- skills/lark-mail/references/lark-mail-reply.md | 4 ++-- skills/lark-mail/references/lark-mail-send.md | 4 ++-- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/shortcuts/mail/draft/patch.go b/shortcuts/mail/draft/patch.go index f31bc174..7a55da59 100644 --- a/shortcuts/mail/draft/patch.go +++ b/shortcuts/mail/draft/patch.go @@ -1044,6 +1044,7 @@ func FindOrphanedCIDs(html string, addedCIDs []string) []string { // 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. +// // NOTE: The EML builder path has an equivalent function processInlineImagesForEML // in shortcuts/mail/helpers.go. When adding new validation or processing logic here, // update processInlineImagesForEML as well (or extract a shared function). diff --git a/shortcuts/mail/mail_reply_forward_inline_test.go b/shortcuts/mail/mail_reply_forward_inline_test.go index 09b332fd..68177bab 100644 --- a/shortcuts/mail/mail_reply_forward_inline_test.go +++ b/shortcuts/mail/mail_reply_forward_inline_test.go @@ -13,7 +13,6 @@ import ( ) // stubSourceMessageWithInlineImages registers HTTP stubs for a source message. -// func stubSourceMessageWithInlineImages(reg *httpmock.Registry, bodyHTML string, allImages []map[string]interface{}) { // Profile reg.Register(&httpmock.Stub{ diff --git a/skills/lark-mail/references/lark-mail-draft-create.md b/skills/lark-mail/references/lark-mail-draft-create.md index 73c246e2..7c884be7 100644 --- a/skills/lark-mail/references/lark-mail-draft-create.md +++ b/skills/lark-mail/references/lark-mail-draft-create.md @@ -27,7 +27,7 @@ lark-cli mail +draft-create --to alice@example.com --subject '周报' \ # 不带收件人的 HTML 草稿(用户之后可自行添加) lark-cli mail +draft-create --subject '周报' --body '

草稿内容

' -# 带附件和内嵌图片的 HTML 草稿(推荐:直接用本地路径,自动解析) +# 带附件和内嵌图片的 HTML 草稿(推荐:直接用相对路径,自动解析) lark-cli mail +draft-create --to alice@example.com --subject '预览图' --body '

见附件和图:

' --attach ./report.pdf # 纯文本草稿(仅在内容极简时使用) @@ -43,7 +43,7 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te |------|------|------| | `--to ` | 否 | 完整收件人列表,多个用逗号分隔。支持 `Alice ` 格式。省略时草稿不带收件人(之后可通过 `+draft-edit` 添加) | | `--subject ` | 是 | 草稿主题 | -| `--body ` | 是 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `` 本地路径自动解析为内嵌图片 | +| `--body ` | 是 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) | | `--from ` | 否 | 发件人邮箱地址(作为邮箱选择器)。省略时使用当前登录用户的主邮箱地址 | | `--cc ` | 否 | 完整抄送列表,多个用逗号分隔 | | `--bcc ` | 否 | 完整密送列表,多个用逗号分隔 | @@ -83,10 +83,10 @@ lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_ ### 创建带内嵌图片的 HTML 草稿 -> **推荐方式:** 直接在 `--body` HTML 中使用 ``(本地文件路径),系统会自动创建内嵌 MIME 部分并替换为 `cid:` 引用。 +> **推荐方式:** 直接在 `--body` HTML 中使用 ``(相对路径),系统会自动创建内嵌 MIME 部分并替换为 `cid:` 引用。仅支持相对路径(如 `./logo.png`),不支持绝对路径(如 `/tmp/logo.png`)。 ```bash -# 推荐:直接使用本地文件路径,自动解析为内嵌图片 +# 推荐:直接使用相对路径,自动解析为内嵌图片 lark-cli mail +draft-create \ --to alice@example.com \ --subject '通讯稿' \ diff --git a/skills/lark-mail/references/lark-mail-draft-edit.md b/skills/lark-mail/references/lark-mail-draft-edit.md index 5cb58d29..98143555 100644 --- a/skills/lark-mail/references/lark-mail-draft-edit.md +++ b/skills/lark-mail/references/lark-mail-draft-edit.md @@ -198,7 +198,7 @@ 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 部分会自动清理。详见[在正文中插入内嵌图片](#在正文中插入内嵌图片)。 +> **推荐方式:** 直接在 `set_body`/`set_reply_body` 的 HTML 中使用 ``(相对路径),系统会自动创建 MIME 内嵌部分、生成 CID 并替换为 `cid:` 引用。仅支持相对路径(如 `./logo.png`),不支持绝对路径。删除或替换 `` 标签时,对应的 MIME 部分会自动清理。详见[在正文中插入内嵌图片](#在正文中插入内嵌图片)。 > > `add_inline` 仅在需要精确控制 CID 命名时使用。使用时仍需在 HTML 正文中加入 `` 引用。 @@ -304,13 +304,13 @@ lark-cli mail +draft-edit --draft-id --patch-file ./patch.json ### 在正文中插入内嵌图片 -直接在 `set_body`/`set_reply_body` 的 HTML 中使用本地文件路径即可。系统会自动创建 MIME 内嵌部分并替换为 `cid:` 引用。 +直接在 `set_body`/`set_reply_body` 的 HTML 中使用相对路径即可(如 `./logo.png`,不支持绝对路径)。系统会自动创建 MIME 内嵌部分并替换为 `cid:` 引用。 ```bash # 1. 查看草稿以获取当前 HTML 正文 lark-cli mail +draft-edit --draft-id --inspect -# 2. 编写补丁 — 直接使用本地文件路径(注意:回复草稿用 set_reply_body,普通草稿用 set_body) +# 2. 编写补丁 — 直接使用相对路径(注意:回复草稿用 set_reply_body,普通草稿用 set_body) cat > ./patch.json << 'EOF' { "ops": [ @@ -326,7 +326,7 @@ lark-cli mail +draft-edit --draft-id --patch-file ./patch.json 内嵌图片的增删改通过 HTML 正文自动联动: - **添加**:在 HTML 中写 ``,自动创建 MIME 部分 - **删除**:从 HTML 中移除 `` 标签,对应 MIME 部分自动清理 -- **替换**:将 `src` 改为新的本地路径,旧 MIME 部分自动移除、新部分自动创建 +- **替换**:将 `src` 改为新的相对路径,旧 MIME 部分自动移除、新部分自动创建 > **高级用法:** 需要精确控制 CID 命名时,仍可使用 `add_inline` 手动添加 MIME 部分,并在 HTML 中用 `` 引用。 diff --git a/skills/lark-mail/references/lark-mail-forward.md b/skills/lark-mail/references/lark-mail-forward.md index 68e315b7..56129f28 100644 --- a/skills/lark-mail/references/lark-mail-forward.md +++ b/skills/lark-mail/references/lark-mail-forward.md @@ -39,7 +39,7 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --body '

--to alice@example.com --cc bob@example.com --body '请参考' -# 转发时插入内嵌图片(推荐:直接用本地路径,自动解析) +# 转发时插入内嵌图片(推荐:直接用相对路径,自动解析) lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --body '

详见图示:

' # 纯文本转发(仅在内容极简时使用) @@ -58,7 +58,7 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run |------|------|------| | `--message-id ` | 是 | 被转发的邮件 ID | | `--to ` | 是 | 收件人邮箱,多个用逗号分隔 | -| `--body ` | 否 | 转发时附加的说明文字。推荐使用 HTML 获得富文本排版;也支持纯文本。根据转发正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `` 本地路径自动解析为内嵌图片 | +| `--body ` | 否 | 转发时附加的说明文字。推荐使用 HTML 获得富文本排版;也支持纯文本。根据转发正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) | | `--from ` | 否 | 发件人邮箱地址(默认读取 user_mailboxes.profile.primary_email_address) | | `--cc ` | 否 | 抄送邮箱,多个用逗号分隔 | | `--bcc ` | 否 | 密送邮箱,多个用逗号分隔 | diff --git a/skills/lark-mail/references/lark-mail-reply-all.md b/skills/lark-mail/references/lark-mail-reply-all.md index f96c3952..53753b19 100644 --- a/skills/lark-mail/references/lark-mail-reply-all.md +++ b/skills/lark-mail/references/lark-mail-reply-all.md @@ -42,7 +42,7 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '

同步更新

' -- # 从回复名单中排除某些地址(草稿) lark-cli mail +reply-all --message-id <邮件ID> --body '

见上

' --remove bot@example.com,noreply@example.com -# 回复全部时插入内嵌图片(推荐:直接用本地路径,自动解析) +# 回复全部时插入内嵌图片(推荐:直接用相对路径,自动解析) lark-cli mail +reply-all --message-id <邮件ID> --body '

详见图示:

' # 纯文本回复全部(仅在内容极简时使用) @@ -60,7 +60,7 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run | 参数 | 必填 | 说明 | |------|------|------| | `--message-id ` | 是 | 被回复的邮件 ID | -| `--body ` | 是 | 回复正文。推荐使用 HTML 获得富文本排版;也支持纯文本。根据回复正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `` 本地路径自动解析为内嵌图片 | +| `--body ` | 是 | 回复正文。推荐使用 HTML 获得富文本排版;也支持纯文本。根据回复正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) | | `--from ` | 否 | 发件人邮箱地址(默认读取 user_mailboxes.profile.primary_email_address) | | `--to ` | 否 | 额外收件人,多个用逗号分隔(追加到自动聚合结果) | | `--cc ` | 否 | 额外抄送,多个用逗号分隔 | diff --git a/skills/lark-mail/references/lark-mail-reply.md b/skills/lark-mail/references/lark-mail-reply.md index 848780c0..5d56faf9 100644 --- a/skills/lark-mail/references/lark-mail-reply.md +++ b/skills/lark-mail/references/lark-mail-reply.md @@ -43,7 +43,7 @@ lark-cli mail +reply --message-id <邮件ID> --body '

已收到,稍 # 回复并追加收件人/抄送(保存为草稿) lark-cli mail +reply --message-id <邮件ID> --body '

已处理

' --to lead@example.com --cc colleague@example.com -# 回复时插入内嵌图片(推荐:直接用本地路径,自动解析) +# 回复时插入内嵌图片(推荐:直接用相对路径,自动解析) lark-cli mail +reply --message-id <邮件ID> --body '

详见图示:

' # 纯文本回复(仅在内容极简时使用) @@ -64,7 +64,7 @@ lark-cli mail +reply --message-id <邮件ID> --body '

测试

' --dry-run | 参数 | 必填 | 说明 | |------|------|------| | `--message-id ` | 是 | 被回复的邮件 ID | -| `--body ` | 是 | 回复正文。推荐使用 HTML 获得富文本排版;也支持纯文本。根据回复正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `` 本地路径自动解析为内嵌图片 | +| `--body ` | 是 | 回复正文。推荐使用 HTML 获得富文本排版;也支持纯文本。根据回复正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) | | `--from ` | 否 | 发件人邮箱地址(默认读取 user_mailboxes.profile.primary_email_address) | | `--to ` | 否 | 额外收件人,多个用逗号分隔(追加到原发件人) | | `--cc ` | 否 | 抄送邮箱,多个用逗号分隔 | diff --git a/skills/lark-mail/references/lark-mail-send.md b/skills/lark-mail/references/lark-mail-send.md index ff287e10..6eb3d8bb 100644 --- a/skills/lark-mail/references/lark-mail-send.md +++ b/skills/lark-mail/references/lark-mail-send.md @@ -46,7 +46,7 @@ lark-cli mail +send --to alice@example.com --subject '周报' \ # 保存带附件的草稿 lark-cli mail +send --to alice@example.com --subject '请查收' --body '

见附件

' --attach ./report.pdf,./logs.zip -# 保存带内嵌图片的草稿(推荐:直接用本地路径,自动解析) +# 保存带内嵌图片的草稿(推荐:直接用相对路径,自动解析) lark-cli mail +send --to alice@example.com --subject '预览图' --body '' # 纯文本邮件(仅在内容极简时使用) @@ -62,7 +62,7 @@ lark-cli mail +send --to alice@example.com --subject '测试' --body '

test

` | 是 | 收件人邮箱,多个用逗号分隔 | | `--subject ` | 是 | 邮件主题 | -| `--body ` | 是 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `` 本地路径自动解析为内嵌图片 | +| `--body ` | 是 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) | | `--from ` | 否 | 发件人邮箱地址(默认读取 user_mailboxes.profile.primary_email_address) | | `--cc ` | 否 | 抄送邮箱,多个用逗号分隔 | | `--bcc ` | 否 | 密送邮箱,多个用逗号分隔 | From b91dabf2710cf8e4d7423d2a46c7e2e95c050bf1 Mon Sep 17 00:00:00 2001 From: "fengzhihao.infeng" Date: Thu, 2 Apr 2026 16:32:39 +0800 Subject: [PATCH 4/9] fix(mail): remove orphaned inline parts under multipart/mixed removeOrphanedInlineParts only searched multipart/related containers, but some servers return drafts with inline parts as direct children of multipart/mixed. When set_body replaced HTML with a local , the old inline part survived under multipart/mixed while a new one was created under a freshly wrapped multipart/related, causing CID mismatch. Extend the cleanup to also search multipart/mixed containers and recurse into remaining children after filtering. Add regression test with a multipart/mixed draft containing a direct inline child. --- shortcuts/mail/draft/patch.go | 11 ++- .../mail/draft/patch_inline_resolve_test.go | 95 +++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/shortcuts/mail/draft/patch.go b/shortcuts/mail/draft/patch.go index 7a55da59..20b17b72 100644 --- a/shortcuts/mail/draft/patch.go +++ b/shortcuts/mail/draft/patch.go @@ -975,12 +975,16 @@ func resolveLocalImgSrc(snapshot *DraftSnapshot, html string) (string, error) { } // removeOrphanedInlineParts removes inline MIME parts whose ContentID -// is not in the referencedCIDs set from all multipart/related containers. +// is not in the referencedCIDs set. It searches multipart/related and +// multipart/mixed containers, because some servers flatten the MIME tree +// and place inline parts directly under multipart/mixed. func removeOrphanedInlineParts(root *Part, referencedCIDs map[string]bool) { if root == nil { return } - if !strings.EqualFold(root.MediaType, "multipart/related") { + isRelated := strings.EqualFold(root.MediaType, "multipart/related") + isMixed := strings.EqualFold(root.MediaType, "multipart/mixed") + if !isRelated && !isMixed { for _, child := range root.Children { removeOrphanedInlineParts(child, referencedCIDs) } @@ -1000,6 +1004,9 @@ func removeOrphanedInlineParts(root *Part, referencedCIDs map[string]bool) { kept = append(kept, child) } root.Children = kept + for _, child := range root.Children { + removeOrphanedInlineParts(child, referencedCIDs) + } } // ValidateCIDReferences checks that every cid: reference in the HTML body has diff --git a/shortcuts/mail/draft/patch_inline_resolve_test.go b/shortcuts/mail/draft/patch_inline_resolve_test.go index 2955a329..32d0d429 100644 --- a/shortcuts/mail/draft/patch_inline_resolve_test.go +++ b/shortcuts/mail/draft/patch_inline_resolve_test.go @@ -840,3 +840,98 @@ func TestNewInlinePartRejectsInvalidCIDChars(t *testing.T) { } } } + +// --------------------------------------------------------------------------- +// Regression: orphaned inline under multipart/mixed (not multipart/related) +// --------------------------------------------------------------------------- + +// TestSetBodyReplacesOrphanedInlineUnderMixed reproduces the bug where the +// server returns a draft with an inline part as a direct child of +// multipart/mixed (not wrapped in multipart/related). When set_body replaces +// the HTML with a local , postProcessInlineImages must remove the +// old inline part even though it lives under multipart/mixed. +func TestSetBodyReplacesOrphanedInlineUnderMixed(t *testing.T) { + chdirTemp(t) + os.WriteFile("Peter1.jpeg", []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'}, 0o644) + + // Simulate a server-returned draft where the inline part is a direct + // child of multipart/mixed (no multipart/related wrapper). + snapshot := mustParseFixtureDraft(t, "Subject: Test\r\n"+ + "From: alice@example.com\r\n"+ + "MIME-Version: 1.0\r\n"+ + "Content-Type: multipart/mixed; boundary=outer\r\n"+ + "\r\n"+ + "--outer\r\n"+ + "Content-Type: text/html; charset=UTF-8\r\n"+ + "\r\n"+ + "

111

222

\r\n"+ + "--outer\r\n"+ + "Content-Type: image/jpeg; name=\"Peter1.jpeg\"\r\n"+ + "Content-Disposition: inline; filename=\"Peter1.jpeg\"\r\n"+ + "Content-ID: \r\n"+ + "Content-Transfer-Encoding: base64\r\n"+ + "\r\n"+ + "/9j/4AAQ\r\n"+ + "--outer--\r\n") + + // Verify the old inline part exists before patching. + oldInlineFound := false + for _, part := range flattenParts(snapshot.Body) { + if part != nil && part.ContentID == "peter1-inline" { + oldInlineFound = true + } + } + if !oldInlineFound { + t.Fatal("expected old inline part with CID 'peter1-inline' in parsed draft") + } + + // Apply set_body with a local image path (triggers auto-resolve). + err := Apply(snapshot, Patch{ + Ops: []PatchOp{{Op: "set_body", Value: `

111

222

`}}, + }) + if err != nil { + t.Fatalf("Apply() error = %v", err) + } + + // After apply, the HTML should reference a UUID CID, not peter1-inline. + htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID) + if htmlPart == nil { + t.Fatal("HTML part not found after apply") + } + body := string(htmlPart.Body) + if strings.Contains(body, "peter1-inline") { + t.Fatalf("HTML should not reference old CID 'peter1-inline', got: %s", body) + } + if strings.Contains(body, "./Peter1.jpeg") { + t.Fatal("local path should have been replaced with cid: reference") + } + + // Extract the new CID from HTML. + cidRe := regexp.MustCompile(`src="cid:([^"]+)"`) + m := cidRe.FindStringSubmatch(body) + if m == nil { + t.Fatalf("expected cid: reference in HTML, got: %s", body) + } + newCID := m[1] + + // Verify: the old inline part must be gone, and a new one with the UUID CID must exist. + oldFound := false + newFound := false + for _, part := range flattenParts(snapshot.Body) { + if part == nil { + continue + } + if part.ContentID == "peter1-inline" { + oldFound = true + } + if part.ContentID == newCID { + newFound = true + } + } + if oldFound { + t.Error("old inline part with CID 'peter1-inline' should have been removed") + } + if !newFound { + t.Errorf("new inline part with CID %q should exist", newCID) + } +} From e72433dd2ad01cfd34d1d91fca3fb5b6af68c144 Mon Sep 17 00:00:00 2001 From: "fengzhihao.infeng" Date: Thu, 2 Apr 2026 16:50:36 +0800 Subject: [PATCH 5/9] test(mail): tighten text-only draft and Windows path test cases Assert text/plain body content and absence of inline parts in TestResolveLocalImgSrcNoHTMLBody. Add Windows absolute path cases (C:\, C:/, c:\) to TestIsLocalFileSrc to lock in the intentional rejection behavior. --- shortcuts/mail/draft/patch_inline_resolve_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/shortcuts/mail/draft/patch_inline_resolve_test.go b/shortcuts/mail/draft/patch_inline_resolve_test.go index 32d0d429..e6cd4b3e 100644 --- a/shortcuts/mail/draft/patch_inline_resolve_test.go +++ b/shortcuts/mail/draft/patch_inline_resolve_test.go @@ -590,6 +590,18 @@ Just plain text. if err != nil { t.Fatalf("Apply() error = %v", err) } + textPart := findPrimaryBodyPart(snapshot.Body, "text/plain") + if textPart == nil { + t.Fatal("text/plain part not found") + } + if got := string(textPart.Body); got != "Updated plain text." { + t.Fatalf("text/plain body = %q, want %q", got, "Updated plain text.") + } + for _, part := range flattenParts(snapshot.Body) { + if part != nil && strings.EqualFold(part.ContentDisposition, "inline") && part.ContentID != "" { + t.Fatalf("unexpected inline part with CID %q in text-only draft", part.ContentID) + } + } } // --------------------------------------------------------------------------- @@ -647,6 +659,9 @@ func TestIsLocalFileSrc(t *testing.T) { {"../images/logo.png", true}, {"logo.png", true}, {"/absolute/path/logo.png", true}, + {`C:\images\logo.png`, false}, + {"C:/images/logo.png", false}, + {`c:\path\file.png`, false}, {"cid:logo", false}, {"CID:logo", false}, {"http://example.com/img.png", false}, From 354c14d846019b68e56c8cc65e8aa784dd7bf692 Mon Sep 17 00:00:00 2001 From: "fengzhihao.infeng" Date: Thu, 2 Apr 2026 20:41:52 +0800 Subject: [PATCH 6/9] test(mail): add CRLF case to newInlinePart rejection test Pin the validate.RejectCRLF guard so header-injection protection cannot silently regress. --- shortcuts/mail/draft/patch_inline_resolve_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shortcuts/mail/draft/patch_inline_resolve_test.go b/shortcuts/mail/draft/patch_inline_resolve_test.go index e6cd4b3e..52ee33b7 100644 --- a/shortcuts/mail/draft/patch_inline_resolve_test.go +++ b/shortcuts/mail/draft/patch_inline_resolve_test.go @@ -841,7 +841,7 @@ func TestResolveLocalImagePathsNoImages(t *testing.T) { 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)"} { + for _, bad := range []string{"my logo", "a\tb", "cid", "cid(x)", "cid\r\nx"} { _, err := newInlinePart("test.png", content, bad, "test.png", "image/png") if err == nil { t.Errorf("expected error for CID %q, got nil", bad) From d5045f6a1ba8997d461627ae1020ebf575a8008d Mon Sep 17 00:00:00 2001 From: "fengzhihao.infeng" Date: Tue, 7 Apr 2026 11:22:28 +0800 Subject: [PATCH 7/9] fix(mail): gate local path resolution to body-changing ops and unify CID validation - Only run resolveLocalImgSrc when a body-changing op (set_body, set_reply_body, replace_body, append_body) is present; metadata-only edits like set_subject no longer trigger disk I/O or fail on unreachable local paths in existing HTML. - Extract validateCID shared helper and apply it in both newInlinePart and replaceInline, closing the gap where replace_inline accepted spaces/tabs/brackets in CIDs. - Add tests for metadata-only edit skipping resolution and replace_inline rejecting invalid CID characters. --- shortcuts/mail/draft/patch.go | 65 ++++++++++++++----- .../mail/draft/patch_inline_resolve_test.go | 27 ++++++++ shortcuts/mail/draft/patch_test.go | 17 +++++ 3 files changed, 91 insertions(+), 18 deletions(-) diff --git a/shortcuts/mail/draft/patch.go b/shortcuts/mail/draft/patch.go index 20b17b72..58a0499a 100644 --- a/shortcuts/mail/draft/patch.go +++ b/shortcuts/mail/draft/patch.go @@ -30,16 +30,29 @@ var protectedHeaders = map[string]bool{ "reply-to": true, } +// bodyChangingOps lists patch operations that modify the HTML body content, +// which is the trigger for running local image path resolution. +var bodyChangingOps = map[string]bool{ + "set_body": true, + "set_reply_body": true, + "replace_body": true, + "append_body": true, +} + func Apply(snapshot *DraftSnapshot, patch Patch) error { if err := patch.Validate(); err != nil { return err } + hasBodyChange := false for _, op := range patch.Ops { if err := applyOp(snapshot, op, patch.Options); err != nil { return err } + if bodyChangingOps[op.Op] { + hasBodyChange = true + } } - if err := postProcessInlineImages(snapshot); err != nil { + if err := postProcessInlineImages(snapshot, hasBodyChange); err != nil { return err } return refreshSnapshot(snapshot) @@ -620,7 +633,7 @@ func replaceInline(snapshot *DraftSnapshot, partID, path, cid, fileName, content contentType = detectedCT contentType, mediaParams := normalizedDetectedMediaType(contentType) finalCID := strings.Trim(strings.TrimSpace(cid), "<>") - if err := validate.RejectCRLF(finalCID, "inline cid"); err != nil { + if err := validateCID(finalCID); err != nil { return err } if err := validate.RejectCRLF(fileName, "inline filename"); err != nil { @@ -745,6 +758,21 @@ func findPart(root *Part, partID string) *Part { return nil } +// validateCID checks that a Content-ID value is non-empty and free of +// characters that would break MIME headers or cause ambiguous references. +func validateCID(cid string) error { + if cid == "" { + return fmt.Errorf("inline cid is empty") + } + if err := validate.RejectCRLF(cid, "inline cid"); err != nil { + return err + } + if strings.ContainsAny(cid, " \t<>()") { + return fmt.Errorf("inline cid %q contains invalid characters (spaces, tabs, angle brackets, or parentheses are not allowed)", cid) + } + return nil +} + func ensureInlineContainerRef(partRef **Part) (*Part, error) { if partRef == nil || *partRef == nil { return nil, fmt.Errorf("body container is nil") @@ -770,15 +798,9 @@ func newInlinePart(path string, content []byte, cid, fileName, contentType strin contentType, mediaParams := normalizedDetectedMediaType(contentType) mediaParams["name"] = fileName cid = strings.Trim(strings.TrimSpace(cid), "<>") - if cid == "" { - return nil, fmt.Errorf("inline cid is empty") - } - if err := validate.RejectCRLF(cid, "inline cid"); err != nil { + if err := validateCID(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 } @@ -1048,27 +1070,34 @@ func FindOrphanedCIDs(html string, addedCIDs []string) []string { } // postProcessInlineImages is the unified post-processing step that: -// 1. Resolves local to inline CID parts. +// 1. Resolves local to inline CID parts (only when resolveLocal is true). // 2. Validates all CID references in HTML resolve to MIME parts. // 3. Removes orphaned inline MIME parts no longer referenced by HTML. // +// resolveLocal should be true only when a body-changing op was applied; +// metadata-only edits skip local path resolution to avoid disk I/O side effects. +// // NOTE: The EML builder path has an equivalent function processInlineImagesForEML // in shortcuts/mail/helpers.go. When adding new validation or processing logic here, // update processInlineImagesForEML as well (or extract a shared function). -func postProcessInlineImages(snapshot *DraftSnapshot) error { +func postProcessInlineImages(snapshot *DraftSnapshot, resolveLocal bool) error { htmlPart := findPrimaryBodyPart(snapshot.Body, "text/html") if htmlPart == nil { return nil } origHTML := string(htmlPart.Body) - html, err := resolveLocalImgSrc(snapshot, origHTML) - if err != nil { - return err - } - if html != origHTML { - htmlPart.Body = []byte(html) - htmlPart.Dirty = true + html := origHTML + if resolveLocal { + var err error + html, err = resolveLocalImgSrc(snapshot, origHTML) + if err != nil { + return err + } + if html != origHTML { + htmlPart.Body = []byte(html) + htmlPart.Dirty = true + } } // Collect all CIDs present as MIME parts. diff --git a/shortcuts/mail/draft/patch_inline_resolve_test.go b/shortcuts/mail/draft/patch_inline_resolve_test.go index 52ee33b7..daa61a76 100644 --- a/shortcuts/mail/draft/patch_inline_resolve_test.go +++ b/shortcuts/mail/draft/patch_inline_resolve_test.go @@ -950,3 +950,30 @@ func TestSetBodyReplacesOrphanedInlineUnderMixed(t *testing.T) { t.Errorf("new inline part with CID %q should exist", newCID) } } + +// --------------------------------------------------------------------------- +// Metadata-only edit must NOT trigger local path resolution +// --------------------------------------------------------------------------- + +// TestMetadataEditSkipsLocalPathResolve ensures that a pure metadata edit +// (set_subject) does not attempt to resolve paths from +// disk. If the HTML happens to contain a local path (e.g. from an external +// client), the edit should still succeed without file I/O. +func TestMetadataEditSkipsLocalPathResolve(t *testing.T) { + // Draft HTML contains a local path that does NOT exist on disk. + // A body-changing op would fail trying to read this file. + snapshot := mustParseFixtureDraft(t, `Subject: Original +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: text/html; charset=UTF-8 + +
Hello
+`) + err := Apply(snapshot, Patch{ + Ops: []PatchOp{{Op: "set_subject", Value: "Updated subject"}}, + }) + if err != nil { + t.Fatalf("metadata-only edit should not trigger local path resolution, got: %v", err) + } +} diff --git a/shortcuts/mail/draft/patch_test.go b/shortcuts/mail/draft/patch_test.go index e3c55a9e..030d914a 100644 --- a/shortcuts/mail/draft/patch_test.go +++ b/shortcuts/mail/draft/patch_test.go @@ -652,6 +652,23 @@ func TestReplaceInlineRejectsCRLFInCID(t *testing.T) { } } +func TestReplaceInlineRejectsInvalidCIDChars(t *testing.T) { + fixtureData := mustReadFixture(t, "testdata/html_inline_draft.eml") + chdirTemp(t) + if err := os.WriteFile("updated.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + snapshot := mustParseFixtureDraft(t, fixtureData) + for _, bad := range []string{"my logo", "a\tb", "cid", "cid(x)"} { + err := Apply(snapshot, Patch{ + Ops: []PatchOp{{Op: "replace_inline", Target: AttachmentTarget{PartID: "1.2"}, Path: "updated.png", CID: bad}}, + }) + if err == nil { + t.Errorf("expected error for CID %q, got nil", bad) + } + } +} + func TestReplaceInlineRejectsCRLFInFileName(t *testing.T) { fixtureData := mustReadFixture(t, "testdata/html_inline_draft.eml") chdirTemp(t) From e3decc7e94d11a24ff272521534e095dd7577a32 Mon Sep 17 00:00:00 2001 From: "fengzhihao.infeng" Date: Tue, 7 Apr 2026 12:01:19 +0800 Subject: [PATCH 8/9] fix(mail): validate CIDs against user-authored body only in reply/forward validateInlineCIDs was called with fullHTML (user body + quoted source), so a broken CID in the quoted original message would block reply/forward even though the user's own body is valid. Validate against resolved (user-authored portion) only, since quoted HTML CID integrity is outside user control. --- shortcuts/mail/mail_forward.go | 2 +- shortcuts/mail/mail_reply.go | 2 +- shortcuts/mail/mail_reply_all.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/shortcuts/mail/mail_forward.go b/shortcuts/mail/mail_forward.go index 63142532..0d3f5746 100644 --- a/shortcuts/mail/mail_forward.go +++ b/shortcuts/mail/mail_forward.go @@ -153,7 +153,7 @@ var MailForward = common.Shortcut{ bld = bld.AddFileInline(spec.FilePath, spec.CID) userCIDs = append(userCIDs, spec.CID) } - if err := validateInlineCIDs(fullHTML, userCIDs, srcCIDs); err != nil { + if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil { return err } } else { diff --git a/shortcuts/mail/mail_reply.go b/shortcuts/mail/mail_reply.go index 635746ad..e4e64511 100644 --- a/shortcuts/mail/mail_reply.go +++ b/shortcuts/mail/mail_reply.go @@ -154,7 +154,7 @@ var MailReply = common.Shortcut{ bld = bld.AddFileInline(spec.FilePath, spec.CID) userCIDs = append(userCIDs, spec.CID) } - if err := validateInlineCIDs(fullHTML, userCIDs, srcCIDs); err != nil { + if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil { return err } } else { diff --git a/shortcuts/mail/mail_reply_all.go b/shortcuts/mail/mail_reply_all.go index a567f0c2..8a5e0124 100644 --- a/shortcuts/mail/mail_reply_all.go +++ b/shortcuts/mail/mail_reply_all.go @@ -168,7 +168,7 @@ var MailReplyAll = common.Shortcut{ bld = bld.AddFileInline(spec.FilePath, spec.CID) userCIDs = append(userCIDs, spec.CID) } - if err := validateInlineCIDs(fullHTML, userCIDs, srcCIDs); err != nil { + if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil { return err } } else { From 0499b089d2ab5cc66d4b8ffcb935520949583f25 Mon Sep 17 00:00:00 2001 From: "fengzhihao.infeng" Date: Tue, 7 Apr 2026 21:10:26 +0800 Subject: [PATCH 9/9] fix(mail): reject angle brackets in CID instead of silently stripping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit strings.Trim(cid, "<>") stripped all leading/trailing angle brackets, so "test<>" became "test" and passed validation — causing a confusing "orphaned cids" error instead of "invalid characters". Replace with normalizeCID() that only unwraps a matched RFC 2392 <...> pair. Add test cases for stray angle brackets and valid CIDs. --- shortcuts/mail/draft/patch.go | 22 ++++++++++++------- .../mail/draft/patch_inline_resolve_test.go | 6 ++--- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/shortcuts/mail/draft/patch.go b/shortcuts/mail/draft/patch.go index 2feb6053..f23f93f2 100644 --- a/shortcuts/mail/draft/patch.go +++ b/shortcuts/mail/draft/patch.go @@ -632,13 +632,10 @@ func replaceInline(snapshot *DraftSnapshot, partID, path, cid, fileName, content } contentType = detectedCT contentType, mediaParams := normalizedDetectedMediaType(contentType) - finalCID := strings.Trim(strings.TrimSpace(cid), "<>") + finalCID := normalizeCID(cid) if err := validateCID(finalCID); err != nil { return err } - if strings.ContainsAny(finalCID, " \t<>()") { - return fmt.Errorf("inline cid %q contains invalid characters (spaces, tabs, angle brackets, or parentheses are not allowed)", finalCID) - } if err := validate.RejectCRLF(fileName, "inline filename"); err != nil { return err } @@ -761,6 +758,18 @@ func findPart(root *Part, partID string) *Part { return nil } +// normalizeCID strips a single RFC 2392 angle-bracket wrapper (<...>) from the +// CID if present, and trims surrounding whitespace. Unlike strings.Trim, it +// only removes a matched pair so that stray brackets like "test<>" are preserved +// for validation to reject. +func normalizeCID(cid string) string { + cid = strings.TrimSpace(cid) + if strings.HasPrefix(cid, "<") && strings.HasSuffix(cid, ">") { + cid = cid[1 : len(cid)-1] + } + return cid +} + // validateCID checks that a Content-ID value is non-empty and free of // characters that would break MIME headers or cause ambiguous references. func validateCID(cid string) error { @@ -800,13 +809,10 @@ func newInlinePart(path string, content []byte, cid, fileName, contentType strin } contentType, mediaParams := normalizedDetectedMediaType(contentType) mediaParams["name"] = fileName - cid = strings.Trim(strings.TrimSpace(cid), "<>") + cid = normalizeCID(cid) if err := validateCID(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 } diff --git a/shortcuts/mail/draft/patch_inline_resolve_test.go b/shortcuts/mail/draft/patch_inline_resolve_test.go index daa61a76..d45e0019 100644 --- a/shortcuts/mail/draft/patch_inline_resolve_test.go +++ b/shortcuts/mail/draft/patch_inline_resolve_test.go @@ -841,14 +841,14 @@ func TestResolveLocalImagePathsNoImages(t *testing.T) { 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)", "cid\r\nx"} { + for _, bad := range []string{"my logo", "a\tb", "cid", "cid(x)", "cid\r\nx", "test<>", "<>bad"} { _, 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"} { + // Valid CIDs should pass (including RFC <...> wrapper which gets unwrapped). + 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)