diff --git a/shortcuts/mail/draft/patch.go b/shortcuts/mail/draft/patch.go index e4f3be36..f23f93f2 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, @@ -24,22 +30,32 @@ 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 := refreshSnapshot(snapshot); err != nil { - return err - } - if err := validateInlineCIDAfterApply(snapshot); err != nil { + if err := postProcessInlineImages(snapshot, hasBodyChange); err != nil { return err } - return validateOrphanedInlineCIDAfterApply(snapshot) + return refreshSnapshot(snapshot) } func applyOp(snapshot *DraftSnapshot, op PatchOp, options PatchOptions) error { @@ -523,21 +539,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 +565,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 - } - containerRef := primaryBodyRootRef(&snapshot.Body) - if containerRef == nil || *containerRef == nil { - return fmt.Errorf("draft has no primary body container") + return nil, fmt.Errorf("inline image %q: %w", path, err) } - 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 { @@ -605,13 +632,10 @@ 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 { + 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 } @@ -734,6 +758,33 @@ 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 { + 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") @@ -758,16 +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), "<>") - if cid == "" { - return nil, fmt.Errorf("inline cid is empty") - } - if err := validate.RejectCRLF(cid, "inline cid"); err != nil { + 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 } @@ -863,59 +908,227 @@ 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 := findPrimaryBodyPart(snapshot.Body, "text/html") - 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 +} + +// 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, nil + } + + // 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-- { + srcStart, srcEnd := matches[i][2], matches[i][3] + src := html[srcStart:srcEnd] + if !isLocalFileSrc(src) { + continue + } + + resolvedPath, err := validate.SafeInputPath(src) + if err != nil { + return "", nil, fmt.Errorf("inline image %q: %w", src, err) + } + + cid, ok := pathToCID[resolvedPath] + if !ok { + cid, err = generateCID() + if err != nil { + return "", nil, err + } + pathToCID[resolvedPath] = cid + refs = append(refs, LocalImageRef{FilePath: src, CID: cid}) + } + + html = html[:srcStart] + "cid:" + cid + html[srcEnd:] + } + + 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 +// 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 + } + 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) + } + return + } + kept := make([]*Part, 0, len(root.Children)) + for _, child := range root.Children { + if child == nil { continue } - cids[strings.ToLower(part.ContentID)] = true + 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 + for _, child := range root.Children { + removeOrphanedInlineParts(child, referencedCIDs) + } +} + +// 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 !cids[strings.ToLower(ref)] { + if !cidSet[strings.ToLower(ref)] { return fmt.Errorf("html body references missing inline cid %q", ref) } } return nil } -// 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 := findPrimaryBodyPart(snapshot.Body, "text/html") - if htmlPart == nil { - return nil - } - refs := extractCIDRefs(string(htmlPart.Body)) +// 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 _, part := range flattenParts(snapshot.Body) { - if part == nil || part.ContentID == "" { - continue + for _, cid := range addedCIDs { + if !refSet[strings.ToLower(cid)] { + orphaned = append(orphaned, cid) } - if !refSet[strings.ToLower(part.ContentID)] { - orphaned = append(orphaned, part.ContentID) + } + return orphaned +} + +// postProcessInlineImages is the unified post-processing step that: +// 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, resolveLocal bool) error { + htmlPart := findPrimaryBodyPart(snapshot.Body, "text/html") + if htmlPart == nil { + return nil + } + + origHTML := string(htmlPart.Body) + 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. + var cidParts []string + for _, part := range flattenParts(snapshot.Body) { + if part != nil && part.ContentID != "" { + cidParts = append(cidParts, part.ContentID) } } - 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) + + 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 new file mode 100644 index 00000000..d45e0019 --- /dev/null +++ b/shortcuts/mail/draft/patch_inline_resolve_test.go @@ -0,0 +1,979 @@ +// 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) + } + 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) + } + } +} + +// --------------------------------------------------------------------------- +// 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}, + {`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}, + {"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) + } + }) + } +} + +// --------------------------------------------------------------------------- +// 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 +// --------------------------------------------------------------------------- + +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", "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 (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) + } + } +} + +// --------------------------------------------------------------------------- +// 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) + } +} + +// --------------------------------------------------------------------------- +// 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 a69417bd..f6543d13 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") + } } } @@ -641,12 +647,18 @@ Content-Type: text/html; charset=UTF-8 t.Fatalf("Apply(set_body) error = %v", err) } - // Step 3: set_body again dropping the CID reference — should fail validation + // Step 3: set_body again dropping the CID reference — orphaned inline part + // should be auto-removed (not error), matching the auto-cleanup behavior. err = Apply(snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: `
no image here
`}}, }) - if err == nil || !strings.Contains(err.Error(), "orphaned cids") { - t.Fatalf("expected orphaned cid error, got: %v", err) + if err != nil { + t.Fatalf("Apply(set_body drop CID) error = %v", err) + } + for _, part := range flattenParts(snapshot.Body) { + if part != nil && part.ContentID == "logo" { + t.Fatal("expected orphaned inline part 'logo' to be auto-removed") + } } } @@ -732,6 +744,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) diff --git a/shortcuts/mail/helpers.go b/shortcuts/mail/helpers.go index 193f4f46..237044f0 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" ) @@ -1770,11 +1771,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 == "" { @@ -1785,8 +1808,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. @@ -1961,13 +1985,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_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/shortcuts/mail/mail_forward.go b/shortcuts/mail/mail_forward.go index 905bc8af..0d3f5746 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(resolved, 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..e4e64511 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(resolved, 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..8a5e0124 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(resolved, 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..68177bab --- /dev/null +++ b/shortcuts/mail/mail_reply_forward_inline_test.go @@ -0,0 +1,275 @@ +// 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..7c884be7 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:` 引用。仅支持相对路径(如 `./logo.png`),不支持绝对路径(如 `/tmp/logo.png`)。 + ```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-draft-edit.md b/skills/lark-mail/references/lark-mail-draft-edit.md index 6831700c..98143555 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:` 引用。仅支持相对路径(如 `./logo.png`),不支持绝对路径。删除或替换 `` 标签时,对应的 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 中使用相对路径即可(如 `./logo.png`,不支持绝对路径)。系统会自动创建 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 diff --git a/skills/lark-mail/references/lark-mail-forward.md b/skills/lark-mail/references/lark-mail-forward.md index 81fbc2c6..56129f28 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..53753b19 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..5d56faf9 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..6eb3d8bb 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` | 否 | 仅打印请求,不执行 |