revert: undo auto-resolve local image paths in draft body HTML (#199)

* Revert "fix(mail): clarify that file path flags only accept relative paths (#141)"

This reverts commit 1ffe870dc8.

* Revert "feat(mail): auto-resolve local image paths in draft body HTML (#81) (#139)"

This reverts commit 70c72a2c02.

* Reapply "fix(mail): clarify that file path flags only accept relative paths (#141)"

This reverts commit d465e085b1.
This commit is contained in:
feng zhi hao
2026-04-01 23:11:30 +08:00
committed by GitHub
parent a703202ef8
commit eda2b9cd85
5 changed files with 86 additions and 978 deletions

View File

@@ -8,18 +8,12 @@ import (
"mime"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/google/uuid"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/mail/filecheck"
)
// imgSrcRegexp matches <img ... src="value" ...> and captures the src value.
// It handles both single and double quotes.
var imgSrcRegexp = regexp.MustCompile(`(?i)<img\s(?:[^>]*?\s)?src\s*=\s*["']([^"']+)["']`)
var protectedHeaders = map[string]bool{
"message-id": true,
"mime-version": true,
@@ -39,10 +33,13 @@ func Apply(snapshot *DraftSnapshot, patch Patch) error {
return err
}
}
if err := postProcessInlineImages(snapshot); err != nil {
if err := refreshSnapshot(snapshot); err != nil {
return err
}
return refreshSnapshot(snapshot)
if err := validateInlineCIDAfterApply(snapshot); err != nil {
return err
}
return validateOrphanedInlineCIDAfterApply(snapshot)
}
func applyOp(snapshot *DraftSnapshot, op PatchOp, options PatchOptions) error {
@@ -526,25 +523,21 @@ func addAttachment(snapshot *DraftSnapshot, path string) error {
return nil
}
// loadAndAttachInline reads a local image file, validates its format,
// creates a MIME inline part, and attaches it to the snapshot's
// multipart/related container. If container is non-nil it is reused;
// otherwise the container is resolved from the snapshot.
func loadAndAttachInline(snapshot *DraftSnapshot, path, cid, fileName string, container *Part) (*Part, error) {
func addInline(snapshot *DraftSnapshot, path, cid, fileName, contentType string) error {
safePath, err := validate.SafeInputPath(path)
if err != nil {
return nil, fmt.Errorf("inline image %q: %w", path, err)
return fmt.Errorf("inline image %q: %w", path, err)
}
info, err := os.Stat(safePath)
if err != nil {
return nil, fmt.Errorf("inline image %q: %w", path, err)
return err
}
if err := checkSnapshotAttachmentLimit(snapshot, info.Size(), nil); err != nil {
return nil, err
return err
}
content, err := os.ReadFile(safePath)
if err != nil {
return nil, fmt.Errorf("inline image %q: %w", path, err)
return err
}
name := fileName
if strings.TrimSpace(name) == "" {
@@ -552,30 +545,23 @@ func loadAndAttachInline(snapshot *DraftSnapshot, path, cid, fileName string, co
}
detectedCT, err := filecheck.CheckInlineImageFormat(name, content)
if err != nil {
return nil, fmt.Errorf("inline image %q: %w", path, err)
return err
}
inline, err := newInlinePart(safePath, content, cid, name, detectedCT)
inline, err := newInlinePart(path, content, cid, fileName, detectedCT)
if err != nil {
return nil, fmt.Errorf("inline image %q: %w", path, err)
return err
}
if container == nil {
containerRef := primaryBodyRootRef(&snapshot.Body)
if containerRef == nil || *containerRef == nil {
return nil, fmt.Errorf("draft has no primary body container")
}
container, err = ensureInlineContainerRef(containerRef)
if err != nil {
return nil, fmt.Errorf("inline image %q: %w", path, err)
}
containerRef := primaryBodyRootRef(&snapshot.Body)
if containerRef == nil || *containerRef == nil {
return fmt.Errorf("draft has no primary body container")
}
container, err := ensureInlineContainerRef(containerRef)
if err != nil {
return err
}
container.Children = append(container.Children, inline)
container.Dirty = true
return container, nil
}
func addInline(snapshot *DraftSnapshot, path, cid, fileName, contentType string) error {
_, err := loadAndAttachInline(snapshot, path, cid, fileName, nil)
return err
return nil
}
func replaceInline(snapshot *DraftSnapshot, partID, path, cid, fileName, contentType string) error {
@@ -776,9 +762,6 @@ func newInlinePart(path string, content []byte, cid, fileName, contentType strin
if err := validate.RejectCRLF(cid, "inline cid"); err != nil {
return nil, err
}
if strings.ContainsAny(cid, " \t<>()") {
return nil, fmt.Errorf("inline cid %q contains invalid characters (spaces, tabs, angle brackets, or parentheses are not allowed)", cid)
}
if err := validate.RejectCRLF(fileName, "inline filename"); err != nil {
return nil, err
}
@@ -874,152 +857,59 @@ func removeHeader(headers *[]Header, name string) {
*headers = next
}
// uriSchemeRegexp matches a URI scheme (RFC 3986: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":").
var uriSchemeRegexp = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.\-]*:`)
// isLocalFileSrc returns true if src is a local file path.
// Any URI with a scheme (http:, cid:, data:, ftp:, blob:, file:, etc.)
// or protocol-relative URL (//host/...) is rejected.
func isLocalFileSrc(src string) bool {
trimmed := strings.TrimSpace(src)
if trimmed == "" {
return false
}
if strings.HasPrefix(trimmed, "//") {
return false
}
return !uriSchemeRegexp.MatchString(trimmed)
}
// generateCID returns a random UUID string suitable for use as a Content-ID.
// UUIDs contain only [0-9a-f-], which is inherently RFC-safe and unique,
// avoiding all filename-derived encoding/collision issues.
func generateCID() (string, error) {
id, err := uuid.NewRandom()
if err != nil {
return "", fmt.Errorf("failed to generate CID: %w", err)
}
return id.String(), nil
}
// resolveLocalImgSrc scans HTML for <img src="local/path"> references,
// creates MIME inline parts for each local file, and returns the HTML
// with those src attributes replaced by cid: URIs.
func resolveLocalImgSrc(snapshot *DraftSnapshot, html string) (string, error) {
matches := imgSrcRegexp.FindAllStringSubmatchIndex(html, -1)
if len(matches) == 0 {
return html, nil
}
var container *Part
// Cache resolved paths so the same file is only attached once.
pathToCID := make(map[string]string)
// Iterate in reverse so that index offsets remain valid after replacement.
for i := len(matches) - 1; i >= 0; i-- {
srcStart, srcEnd := matches[i][2], matches[i][3]
src := html[srcStart:srcEnd]
if !isLocalFileSrc(src) {
continue
}
resolvedPath, err := validate.SafeInputPath(src)
if err != nil {
return "", fmt.Errorf("inline image %q: %w", src, err)
}
cid, ok := pathToCID[resolvedPath]
if !ok {
fileName := filepath.Base(src)
cid, err = generateCID()
if err != nil {
return "", err
}
pathToCID[resolvedPath] = cid
container, err = loadAndAttachInline(snapshot, src, cid, fileName, container)
if err != nil {
return "", err
}
}
html = html[:srcStart] + "cid:" + cid + html[srcEnd:]
}
return html, nil
}
// removeOrphanedInlineParts removes inline MIME parts whose ContentID
// is not in the referencedCIDs set from all multipart/related containers.
func removeOrphanedInlineParts(root *Part, referencedCIDs map[string]bool) {
if root == nil {
return
}
if !strings.EqualFold(root.MediaType, "multipart/related") {
for _, child := range root.Children {
removeOrphanedInlineParts(child, referencedCIDs)
}
return
}
kept := make([]*Part, 0, len(root.Children))
for _, child := range root.Children {
if child == nil {
continue
}
if strings.EqualFold(child.ContentDisposition, "inline") && child.ContentID != "" {
if !referencedCIDs[strings.ToLower(child.ContentID)] {
root.Dirty = true
continue
}
}
kept = append(kept, child)
}
root.Children = kept
}
// postProcessInlineImages is the unified post-processing step that:
// 1. Resolves local <img src="./path"> to inline CID parts.
// 2. Validates all CID references in HTML resolve to MIME parts.
// 3. Removes orphaned inline MIME parts no longer referenced by HTML.
func postProcessInlineImages(snapshot *DraftSnapshot) error {
htmlPart := findPrimaryBodyPart(snapshot.Body, "text/html")
// validateInlineCIDAfterApply checks that all CID references in the HTML body
// resolve to actual inline MIME parts. This is called after Apply (editing) to
// prevent broken CID references, but NOT during Parse (where broken CIDs
// should not block opening the draft).
func validateInlineCIDAfterApply(snapshot *DraftSnapshot) error {
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if htmlPart == nil {
return nil
}
origHTML := string(htmlPart.Body)
// Note: if resolveLocalImgSrc returns an error after partially attaching
// inline parts to the snapshot, those parts are orphaned but will be
// cleaned up by removeOrphanedInlineParts on the next successful Apply.
html, err := resolveLocalImgSrc(snapshot, origHTML)
if err != nil {
return err
refs := extractCIDRefs(string(htmlPart.Body))
if len(refs) == 0 {
return nil
}
if html != origHTML {
htmlPart.Body = []byte(html)
htmlPart.Dirty = true
}
refs := extractCIDRefs(html)
refSet := make(map[string]bool, len(refs))
for _, ref := range refs {
refSet[strings.ToLower(ref)] = true
}
cidParts := make(map[string]bool)
cids := make(map[string]bool)
for _, part := range flattenParts(snapshot.Body) {
if part == nil || part.ContentID == "" {
continue
}
cidParts[strings.ToLower(part.ContentID)] = true
cids[strings.ToLower(part.ContentID)] = true
}
for _, ref := range refs {
if !cidParts[strings.ToLower(ref)] {
if !cids[strings.ToLower(ref)] {
return fmt.Errorf("html body references missing inline cid %q", ref)
}
}
removeOrphanedInlineParts(snapshot.Body, refSet)
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 <img src="cid:...">) will
// be displayed as an unexpected attachment by most mail clients.
func validateOrphanedInlineCIDAfterApply(snapshot *DraftSnapshot) error {
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if htmlPart == nil {
return nil
}
refs := extractCIDRefs(string(htmlPart.Body))
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
}
if !refSet[strings.ToLower(part.ContentID)] {
orphaned = append(orphaned, part.ContentID)
}
}
if len(orphaned) > 0 {
return fmt.Errorf("inline MIME parts have no <img> reference in the HTML body and will appear as unexpected attachments: orphaned cids %v; if you used set_body, make sure the new body preserves all existing cid:... references", orphaned)
}
return nil
}

View File

@@ -1,773 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package draft
import (
"os"
"regexp"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// resolveLocalImgSrc — basic auto-resolve
// ---------------------------------------------------------------------------
func TestResolveLocalImgSrcBasic(t *testing.T) {
chdirTemp(t)
os.WriteFile("logo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
snapshot := mustParseFixtureDraft(t, `Subject: Test
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<div>Hello<img src="./logo.png" /></div>
`)
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<div>Hello<img src="./logo.png" /></div>`}},
})
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 <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<div>empty</div>
`)
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<div><img src="./a.png" /><img src="./b.jpg" /></div>`}},
})
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 <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/related; boundary="rel"
--rel
Content-Type: text/html; charset=UTF-8
<div><img src="cid:existing" /><img src="https://example.com/img.png" /><img src="data:image/png;base64,abc" /></div>
--rel
Content-Type: image/png; name=existing.png
Content-Disposition: inline; filename=existing.png
Content-ID: <existing>
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 <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<div>empty</div>
`)
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<div><img src="./logo.png" /><img src="./sub/logo.png" /></div>`}},
})
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 <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<div>empty</div>
`)
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<div><img src="./logo.png" /><p>text</p><img src="./logo.png" /></div>`}},
})
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 <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<div>empty</div>
`)
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<div><img src="./doc.txt" /></div>`}},
})
if err == nil {
t.Fatal("expected error for non-image file")
}
}
// ---------------------------------------------------------------------------
// orphan cleanup — delete inline image by removing <img> from body
// ---------------------------------------------------------------------------
func TestOrphanCleanupOnImgRemoval(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Inline
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/related; boundary="rel"
--rel
Content-Type: text/html; charset=UTF-8
<div>hello<img src="cid:logo" /></div>
--rel
Content-Type: image/png; name=logo.png
Content-Disposition: inline; filename=logo.png
Content-ID: <logo>
Content-Transfer-Encoding: base64
cG5n
--rel--
`)
// Remove the <img> tag from body.
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: "<div>hello</div>"}},
})
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 <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/related; boundary="rel"
--rel
Content-Type: text/html; charset=UTF-8
<div><img src="cid:old" /></div>
--rel
Content-Type: image/png; name=old.png
Content-Disposition: inline; filename=old.png
Content-ID: <old>
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: `<div><img src="./new.png" /></div>`}},
})
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 <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<div>original reply</div><div class="history-quote-wrapper"><div>quoted text</div></div>
`)
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_reply_body", Value: `<div>new reply<img src="./photo.png" /></div>`}},
})
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 <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<div>empty</div>
`)
err := Apply(snapshot, Patch{
Ops: []PatchOp{
{Op: "add_inline", Path: "a.png", CID: "a"},
{Op: "set_body", Value: `<div><img src="cid:a" /><img src="./b.png" /></div>`},
},
})
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 <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<div>empty</div>
`)
// 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: `<div><img src="./logo.png" /></div>`},
},
})
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 <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/related; boundary="rel"
--rel
Content-Type: text/html; charset=UTF-8
<div><img src="cid:logo" /></div>
--rel
Content-Type: image/png; name=logo.png
Content-Disposition: inline; filename=logo.png
Content-ID: <logo>
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: `<div><img src="cid:logo" /></div>`},
},
})
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 <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/related; boundary="rel"
--rel
Content-Type: text/html; charset=UTF-8
<div><img src="cid:old" /></div>
--rel
Content-Type: image/png; name=old.png
Content-Disposition: inline; filename=old.png
Content-ID: <old>
Content-Transfer-Encoding: base64
cG5n
--rel--
`)
err := Apply(snapshot, Patch{
Ops: []PatchOp{
{Op: "remove_inline", Target: AttachmentTarget{CID: "old"}},
{Op: "set_body", Value: `<div><img src="./new.png" /></div>`},
},
})
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 <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Just plain text.
`)
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: "Updated plain text."}},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
}
// ---------------------------------------------------------------------------
// regression: HTML body with Content-ID must not be removed by orphan cleanup
// ---------------------------------------------------------------------------
func TestOrphanCleanupPreservesHTMLBodyWithContentID(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Test
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/related; boundary="rel"
--rel
Content-Type: text/html; charset=UTF-8
Content-ID: <body-part>
<div>hello world</div>
--rel
Content-Type: image/png; name=logo.png
Content-Disposition: inline; filename=logo.png
Content-ID: <logo>
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 <img src="cid:...">.
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_subject", Value: "Updated subject"}},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
htmlPart := findPrimaryBodyPart(snapshot.Body, "text/html")
if htmlPart == nil {
t.Fatal("HTML body part was deleted by orphan cleanup")
}
if !strings.Contains(string(htmlPart.Body), "hello world") {
t.Fatalf("HTML body content changed unexpectedly: %s", string(htmlPart.Body))
}
}
// ---------------------------------------------------------------------------
// helper unit tests
// ---------------------------------------------------------------------------
func TestIsLocalFileSrc(t *testing.T) {
tests := []struct {
src string
want bool
}{
{"./logo.png", true},
{"../images/logo.png", true},
{"logo.png", true},
{"/absolute/path/logo.png", true},
{"cid:logo", false},
{"CID:logo", false},
{"http://example.com/img.png", false},
{"https://example.com/img.png", false},
{"data:image/png;base64,abc", false},
{"//cdn.example.com/a.png", false},
{"blob:https://example.com/uuid", false},
{"ftp://example.com/file.png", false},
{"file:///local/file.png", false},
{"mailto:test@example.com", false},
{"", false},
}
for _, tt := range tests {
if got := isLocalFileSrc(tt.src); got != tt.want {
t.Errorf("isLocalFileSrc(%q) = %v, want %v", tt.src, got, tt.want)
}
}
}
func TestGenerateCID(t *testing.T) {
seen := make(map[string]bool)
for i := 0; i < 100; i++ {
cid, err := generateCID()
if err != nil {
t.Fatalf("generateCID() error = %v", err)
}
if cid == "" {
t.Fatal("generateCID() returned empty string")
}
if strings.ContainsAny(cid, " \t\r\n<>()") {
t.Fatalf("generateCID() returned CID with invalid characters: %q", cid)
}
if seen[cid] {
t.Fatalf("generateCID() returned duplicate CID: %q", cid)
}
seen[cid] = true
}
}
// ---------------------------------------------------------------------------
// imgSrcRegexp — must not match data-src or similar attribute names
// ---------------------------------------------------------------------------
func TestImgSrcRegexpSkipsDataSrc(t *testing.T) {
tests := []struct {
name string
html string
want string // expected captured src value, empty if no match
}{
{
name: "plain src",
html: `<img src="./logo.png" />`,
want: "./logo.png",
},
{
name: "src with alt before",
html: `<img alt="pic" src="./logo.png" />`,
want: "./logo.png",
},
{
name: "data-src before real src",
html: `<img data-src="lazy.png" src="./logo.png" />`,
want: "./logo.png",
},
{
name: "only data-src, no src",
html: `<img data-src="lazy.png" />`,
want: "",
},
{
name: "x-src before real src",
html: `<img x-src="other.png" src="./real.png" />`,
want: "./real.png",
},
{
name: "single-quoted src",
html: `<img src='./logo.png' />`,
want: "./logo.png",
},
{
name: "multiple spaces before src",
html: `<img src="./logo.png" />`,
want: "./logo.png",
},
{
name: "newline before src",
html: "<img\nsrc=\"./logo.png\" />",
want: "./logo.png",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matches := imgSrcRegexp.FindStringSubmatch(tt.html)
got := ""
if len(matches) > 1 {
got = matches[1]
}
if got != tt.want {
t.Errorf("imgSrcRegexp on %q: got %q, want %q", tt.html, got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// newInlinePart — rejects CIDs with spaces or other invalid characters
// ---------------------------------------------------------------------------
func TestNewInlinePartRejectsInvalidCIDChars(t *testing.T) {
content := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
for _, bad := range []string{"my logo", "a\tb", "cid<x>", "cid(x)"} {
_, err := newInlinePart("test.png", content, bad, "test.png", "image/png")
if err == nil {
t.Errorf("expected error for CID %q, got nil", bad)
}
}
// Valid CIDs should pass.
for _, good := range []string{"logo", "my-logo", "img_01", "photo.2"} {
_, err := newInlinePart("test.png", content, good, "test.png", "image/png")
if err != nil {
t.Errorf("unexpected error for CID %q: %v", good, err)
}
}
}

View File

@@ -460,7 +460,7 @@ func TestRemoveInlineFailsWhenHTMLStillReferencesCID(t *testing.T) {
}
}
func TestApplySetBodyOrphanedInlineCIDIsAutoRemoved(t *testing.T) {
func TestApplySetBodyOrphanedInlineCIDIsRejected(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Inline
From: Alice <alice@example.com>
To: Bob <bob@example.com>
@@ -480,18 +480,12 @@ Content-Transfer-Encoding: base64
cG5n
--rel--
`)
// set_body that drops the existing cid:logo reference → logo is auto-removed
// set_body that drops the existing cid:logo reference → logo becomes orphaned
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: "<div>replaced body without cid reference</div>"}},
})
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
// The orphaned inline part should be removed from the MIME tree.
for _, part := range flattenParts(snapshot.Body) {
if part != nil && part.ContentID == "logo" {
t.Fatal("expected orphaned inline part 'logo' to be removed")
}
if err == nil || !strings.Contains(err.Error(), "orphaned cids") {
t.Fatalf("expected orphaned cid error, got: %v", err)
}
}

View File

@@ -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 (supports <img src=\"./local/path.png\" /> — 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 <img src=\"./local/path.png\" /> — local paths auto-resolved to inline MIME parts)"}},
{"op": "set_body", "shape": map[string]interface{}{"value": "string"}},
{"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; the quote block is re-appended automatically)"}},
{"op": "set_header", "shape": map[string]interface{}{"name": "string", "value": "string"}},
{"op": "remove_header", "shape": map[string]interface{}{"name": "string"}},
{"op": "add_attachment", "shape": map[string]interface{}{"path": "string(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)"}, "note": "advanced: prefer <img src=\"./path\"> in set_body/set_reply_body instead"},
{"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}},
{"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}},
{"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
},
@@ -318,8 +318,8 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
"group": "subject_and_body",
"ops": []map[string]interface{}{
{"op": "set_subject", "shape": map[string]interface{}{"value": "string"}},
{"op": "set_body", "shape": map[string]interface{}{"value": "string (supports <img src=\"./local/path.png\" /> — 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 <img src=\"./local/path.png\" /> — local paths auto-resolved to inline MIME parts)"}},
{"op": "set_body", "shape": map[string]interface{}{"value": "string"}},
{"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; the quote block is re-appended automatically)"}},
},
},
{
@@ -342,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)"}, "note": "advanced: prefer <img src=\"./path\"> in set_body/set_reply_body instead"},
{"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}},
{"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}},
{"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
},
@@ -359,13 +359,12 @@ 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 <img src=\"./local/file.png\" /> in the HTML value — the local path is automatically resolved into an inline MIME part with a generated CID; removing or replacing an <img> tag automatically cleans up or replaces the corresponding MIME part; do NOT use `add_inline` for this; example: {\"op\":\"set_body\",\"value\":\"<div>Hello<img src=\\\"./logo.png\\\" /></div>\"}",
"`add_inline` is an advanced op for precise CID control only — in most cases, use <img src=\"./path\"> 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 <img> tag into the HTML body; to display the image in the body, you must ALSO use set_body/set_reply_body to insert <img src=\"cid:...\"> 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",

View File

@@ -198,9 +198,9 @@ lark-cli mail +draft-edit --draft-id <draft_id> --inspect
{ "op": "add_inline", "path": "./logo.png", "cid": "logo" }
```
> **推荐方式:** 直接在 `set_body`/`set_reply_body` 的 HTML 中使用 `<img src="./logo.png" />`(本地文件路径),系统会自动创建 MIME 内嵌部分、生成 CID 并替换为 `cid:` 引用。删除或替换 `<img>` 标签时,对应的 MIME 部分会自动清理。详见[在正文中插入内嵌图片](#在正文中插入内嵌图片)。
>
> `add_inline` 仅在需要精确控制 CID 命名时使用。使用时仍需在 HTML 正文中加入 `<img src="cid:...">` 引用
> **重要:`add_inline` 仅添加 MIME 二进制部分,不会在 HTML 正文中插入 `<img>` 标签。**
> 如需图片在邮件正文中可见,**必须**同时使用 `set_body` 或 `set_reply_body` 更新 HTML 正文并加入 `<img src="cid:...">` 标签。参见[在正文中插入内嵌图片](#在正文中插入内嵌图片)的完整流程。
> 如果忘记添加 `<img>` 引用,该内嵌部分在发送时会变成孤立附件
`replace_inline`
@@ -304,18 +304,23 @@ lark-cli mail +draft-edit --draft-id <draft_id> --patch-file ./patch.json
### 在正文中插入内嵌图片
直接在 `set_body`/`set_reply_body` HTML 中使用本地文件路径即可。系统会自动创建 MIME 内嵌部分并替换为 `cid:` 引用
添加内嵌图片需要**两个协同编辑**1通过 `add_inline` 添加 MIME 部分2通过 `set_body``set_reply_body` HTML 正文中插入 `<img src="cid:...">` 标签
```bash
# 1. 查看草稿以获取当前 HTML 正文
# 1. 查看草稿以获取当前 HTML 正文和已有的内嵌部分
lark-cli mail +draft-edit --draft-id <draft_id> --inspect
# 返回包含:
# projection.body_html_summary: "<div>原有内容<img src=\"cid:existing.png\" /></div>"
# 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": "<div>内容<img src=\"./logo.png\" /><img src=\"./photo.jpg\" /></div>" }
]
{ "op": "set_body", "value": "<div>原有内容<img src=\"cid:existing.png\" /><img src=\"cid:new-image\" /></div>" },
{ "op": "add_inline", "path": "./new-image.png", "cid": "new-image" }
],
"options": {}
}
EOF
@@ -323,13 +328,6 @@ EOF
lark-cli mail +draft-edit --draft-id <draft_id> --patch-file ./patch.json
```
内嵌图片的增删改通过 HTML 正文自动联动:
- **添加**:在 HTML 中写 `<img src="./image.png" />`,自动创建 MIME 部分
- **删除**:从 HTML 中移除 `<img>` 标签,对应 MIME 部分自动清理
- **替换**:将 `src` 改为新的本地路径,旧 MIME 部分自动移除、新部分自动创建
> **高级用法:** 需要精确控制 CID 命名时,仍可使用 `add_inline` 手动添加 MIME 部分,并在 HTML 中用 `<img src="cid:your-cid">` 引用。
### 使用 patch-file 进行高级编辑
```bash