Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
347 changes: 280 additions & 67 deletions shortcuts/mail/draft/patch.go

Large diffs are not rendered by default.

979 changes: 979 additions & 0 deletions shortcuts/mail/draft/patch_inline_resolve_test.go

Large diffs are not rendered by default.

43 changes: 36 additions & 7 deletions shortcuts/mail/draft/patch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <alice@example.com>
To: Bob <bob@example.com>
Expand All @@ -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: "<div>replaced body without cid reference</div>"}},
})
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")
}
}
}

Expand Down Expand Up @@ -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: `<div>no image here</div>`}},
})
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")
}
}
}

Expand Down Expand Up @@ -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<x>", "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)
Expand Down
39 changes: 32 additions & 7 deletions shortcuts/mail/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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 <img src=\"cid:...\"> in the HTML body and will appear as unexpected attachments; remove unused --inline entries or add matching <img> 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 == "" {
Expand All @@ -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.
Expand Down Expand Up @@ -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)
}
65 changes: 63 additions & 2 deletions shortcuts/mail/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<p>no image</p>`, []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(`<p>no image</p>`, 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 := `<p><img src="cid:src-used" /><img src="cid:user-img" /></p>`
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 := `<p><img src="cid:exists" /><img src="cid:missing" /></p>`
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 := `<p><img src="cid:from-source" /></p>`
err := validateInlineCIDs(html, nil, []string{"from-source"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

func TestValidateInlineCIDs_NoCIDsNoError(t *testing.T) {
err := validateInlineCIDs(`<p>plain text</p>`, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

// ---------------------------------------------------------------------------
// downloadAttachmentContent — size limit enforcement
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
33 changes: 26 additions & 7 deletions shortcuts/mail/mail_draft_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading