Skip to content

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

Merged
infeng merged 8 commits intolarksuite:mainfrom
infeng:fix/mail-add-inline-insert-html
Apr 1, 2026
Merged

feat(mail): auto-resolve local image paths in draft body HTML (#81)#139
infeng merged 8 commits intolarksuite:mainfrom
infeng:fix/mail-add-inline-insert-html

Conversation

@infeng
Copy link
Copy Markdown
Collaborator

@infeng infeng commented Mar 31, 2026

Summary

Allow <img src="./local/path.png" /> in set_body/set_reply_body HTML — local file
paths
are automatically resolved into inline MIME parts with generated CIDs. Inline image
lifecycle
(add/delete/replace) is now fully managed through body HTML, eliminating the need to
manually
pair add_inline with set_body.

Changes

  • Add postProcessInlineImages to unify local path resolution, CID validation, and
    orphan
    cleanup into a single post-processing step, replacing the separate
    validateInlineCIDAfterApply
    and validateOrphanedInlineCIDAfterApply functions
  • Extract loadAndAttachInline shared helper to deduplicate file-read/validate/attach
    logic
    between addInline and resolveLocalImgSrc
  • Cache resolved paths so the same file referenced multiple times creates only one MIME
    part
  • Use whitelist-based URI scheme detection (uriSchemeRegexp) instead of blacklist,
    correctly
    handling blob:, ftp:, file:, // protocol-relative URLs
  • Auto-cleanup orphaned inline MIME parts when <img> tags are removed from body HTML
  • Update --print-patch-template notes and set_body/set_reply_body shape
    descriptions to
    guide AI agents toward the simpler local-path approach
  • Update skill reference lark-mail-draft-edit.md with new inline image workflow

Test Plan

  • make unit-test passed
  • 20 new test cases in patch_inline_resolve_test.go covering:
    • Basic local path resolution and multiple images
    • Same file reuse (single MIME part created)
    • Different files with same name (unique CIDs: logo, logo-2)
    • Skip non-local URIs (cid:, http://, data:, blob:, ftp:, //)
    • Non-image format rejection
    • Orphan cleanup on img removal and replacement
    • set_reply_body with local path (quote block preserved)
    • Mixed add_inline + local path usage
    • Conflict: add_inline same file as local path
    • Conflict: remove_inline but body still references CID
    • Conflict: remove_inline + replace with local path
    • Helper unit tests for isLocalFileSrc, cidFromFileName, uniqueCID
  • Existing tests pass (including updated orphan behavior test)
  • Draft package coverage: 82.5%
  • Manual verification with --print-patch-template confirms AI agent uses
    single-step workflow

Related Issues

Closes #81

Summary by CodeRabbit

  • New Features

    • Drafts now auto-resolve local HTML into inline images, rewriting src to generated cid: values and attaching matching inline parts.
  • Bug Fixes

    • Inline CID validation tightened; orphaned inline attachments are auto-removed; invalid local-image references are rejected.
  • Tests

    • Expanded test suite for inline resolution, CID generation/validation, reuse, conflict ordering, lifecycle and error cases.
  • Documentation

    • Updated draft-edit guidance and examples to show the simplified single-step inline-image workflow.

@infeng infeng requested a review from chanthuang March 31, 2026 08:10
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Mar 31, 2026

Greptile Summary

This PR implements automatic resolution of local <img src="./path.png"> references in draft body HTML into inline MIME parts, eliminating the previous two-step add_inline + set_body workflow. It also changes orphaned-inline handling from a hard error to silent auto-cleanup.

Key changes:

  • New postProcessInlineImages consolidates three formerly separate post-Apply steps (local-path resolution, CID-reference validation, orphan removal) into a single, ordered pass.
  • loadAndAttachInline is extracted as a shared helper, with a container-reuse optimisation so multiple images in one set_body call share the same ensureInlineContainerRef call.
  • CIDs for auto-resolved images are UUID-based (generateCID), avoiding naming collisions and bypassing the whitespace-in-filename issue addressed in a prior round.
  • imgSrcRegexp now requires leading whitespace before src (via the optional (?:[^>]*?\s)? group) so attributes like data-src are correctly skipped — the fix from the previous review thread.
  • isLocalFileSrc uses a RFC 3986 scheme whitelist regexp rather than a blacklist, correctly rejecting blob:, ftp:, file:, and protocol-relative // URLs.
  • Orphan cleanup silently removes inline MIME parts that are no longer referenced by an <img> tag, replacing the previous approach that returned an error for such cases.
  • 20 new test cases cover the full lifecycle (basic resolve, multiple images, same-file reuse, duplicate filenames, non-local URI skipping, non-image rejection, orphan cleanup on removal/replacement, reply-body resolution, mixed add_inline + local-path, and three conflict scenarios).

Confidence Score: 4/5

Safe to merge; all prior blocking concerns are resolved and the implementation is correct across the tested scenarios.

The regex data-src fix, CID whitespace sanitisation, and the documented partial-mutation behaviour are all addressed from prior review rounds. The core logic (reverse-iteration index stability, container reuse, orphan cleanup condition excluding non-inline-disposition parts, idempotency on repeated Apply calls) is correct. Test coverage is comprehensive at 82.5% package coverage with targeted cases for all edge conditions. The only minor outstanding point — the ["']…["'] regex allowing mismatched open/close quotes on malformed HTML — is benign because validate.SafeInputPath rejects any resulting invalid path, so it cannot cause incorrect inline attachment or HTML corruption in practice. Score 4 rather than 5 because that one edge case, though benign, is untested.

shortcuts/mail/draft/patch.go — specifically imgSrcRegexp and resolveLocalImgSrc; all other files are low-risk.

Important Files Changed

Filename Overview
shortcuts/mail/draft/patch.go Core change: replaces the two separate validation functions with a unified postProcessInlineImages step, adds resolveLocalImgSrc for auto-resolving local <img> paths to CID references, and refactors addInline into a shared loadAndAttachInline helper. Logic is correct; previous regex and CID-sanitisation concerns from prior review rounds are addressed.
shortcuts/mail/draft/patch_inline_resolve_test.go New 773-line test file covering basic resolution, multi-image, same-file reuse, duplicate-name deduplication, non-local URI skipping, non-image rejection, orphan cleanup on removal/replacement, reply-body resolution, mixed add_inline + local-path, and conflict scenarios. Coverage is thorough.
shortcuts/mail/draft/patch_test.go Existing test renamed from TestApplySetBodyOrphanedInlineCIDIsRejected to TestApplySetBodyOrphanedInlineCIDIsAutoRemoved and updated to expect silent cleanup instead of an error, reflecting the intentional behaviour change.
shortcuts/mail/mail_draft_edit.go Patch-template documentation updated to surface local-path support in set_body/set_reply_body and to mark add_inline as advanced-only; no logic changes.
skills/lark-mail/references/lark-mail-draft-edit.md Skill reference doc updated to show the simplified single-step workflow (local <img src> in set_body) and demote the two-step add_inline + set_body pattern to an advanced use case.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Apply called] --> B[Execute each PatchOp in order]
    B --> C{op type?}
    C -->|set_body / set_reply_body| D[Write HTML to htmlPart.Body]
    C -->|add_inline| E[loadAndAttachInline → append to multipart/related]
    C -->|remove_inline| F[Remove MIME part from tree]
    C -->|other ops| G[Header / recipient changes]
    D & E & F & G --> H[postProcessInlineImages]
    H --> I[findPrimaryBodyPart → htmlPart]
    I --> J{htmlPart found?}
    J -->|No| K[return nil]
    J -->|Yes| L[resolveLocalImgSrc]
    L --> M{Any img matches?}
    M -->|No| N[return html unchanged]
    M -->|Yes| O[Iterate matches in reverse]
    O --> P{isLocalFileSrc?}
    P -->|No - cid: http: data: //| Q[skip]
    P -->|Yes - relative or absolute path| R{pathToCID cache hit?}
    R -->|Hit| S[reuse existing CID]
    R -->|Miss| T[generateCID UUID]
    T --> U[loadAndAttachInline]
    U --> V[SafeInputPath + CheckInlineImageFormat + newInlinePart]
    V --> W[append to multipart/related container]
    S & W --> X[Replace src value with cid:UUID in html]
    X --> Q
    Q --> O
    N & X --> Y[Update htmlPart.Body if changed]
    Y --> Z[Build refSet from extractCIDRefs]
    Z --> AA[Validate all cid: refs have matching MIME parts]
    AA -->|missing ref| BB[return error]
    AA -->|ok| CC[removeOrphanedInlineParts]
    CC --> DD[Walk tree; remove unreferenced inline children from multipart/related]
    DD --> EE[refreshSnapshot]
    EE --> FF[return nil]
Loading

Reviews (4): Last reviewed commit: "fix(mail): revert orphan cleanup to Cont..." | Re-trigger Greptile

Comment thread shortcuts/mail/draft/patch.go Outdated
Comment thread shortcuts/mail/draft/patch.go Outdated
Comment thread shortcuts/mail/draft/patch.go
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 31, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Apply now auto-resolves local HTML <img src="./..."> references into inline MIME parts: it rewrites HTML to cid: URIs (generating UUID CIDs), creates/validates/removes inline parts, and runs this post-processing before refreshing the snapshot. Tests and docs updated accordingly.

Changes

Cohort / File(s) Summary
Core Implementation
shortcuts/mail/draft/patch.go
Refactored inline-image handling: added loadAndAttachInline() helper, postProcessInlineImages() (rewrites local <img src> to cid:, validates CIDs, removes orphan inline parts), stricter CID validation, UUID CID generation, and moved post-processing to run before refreshSnapshot.
New Tests
shortcuts/mail/draft/patch_inline_resolve_test.go
Added comprehensive tests for auto-resolving local <img> to cid: (set_body / set_reply_body), CID generation/reuse/uniqueness, media-type checks, orphan cleanup, mixed patch ordering, and unit tests for helpers (isLocalFileSrc, imgSrcRegexp, generateCID, newInlinePart).
Test Update
shortcuts/mail/draft/patch_test.go
Renamed/updated test to expect orphan inline parts are auto-removed (no error) and assert removed inline part is absent from flattened MIME tree.
Patch Template
shortcuts/mail/mail_draft_edit.go
Updated patch template text to document single-step workflow: local <img src="./..."> in set_body/set_reply_body auto-creates inline parts; add_inline described as advanced/explicit CID option.
User Docs
skills/lark-mail/references/lark-mail-draft-edit.md
Rewrote documentation and examples to show using local <img src="./..."> in body for automatic inline creation and cleanup; simplified examples and guidance.

Sequence Diagram

sequenceDiagram
    participant User as User
    participant Apply as Apply
    participant Parser as HTML Parser
    participant Loader as loadAndAttachInline
    participant MIME as MIME Container
    participant Post as postProcessInlineImages
    participant Snapshot as Snapshot

    User->>Apply: submit patch with set_body (HTML with local <img src="./img.png">)
    Apply->>Parser: scan HTML for <img src> values
    Parser-->>Apply: returns local file paths
    Apply->>Loader: loadAndAttachInline(path)
    Loader->>MIME: read file, validate image, create/reuse multipart/related part and inline Part (generate CID)
    MIME-->>Loader: attached Part with CID
    Loader-->>Apply: return CID
    Apply->>Post: postProcessInlineImages(snapshot)
    Post->>Post: rewrite local src -> cid:, validate referenced CIDs, remove orphan inline parts
    Post-->>Apply: post-processing complete
    Apply->>Snapshot: refreshSnapshot(snapshot)
    Snapshot-->>User: updated draft with cid: URIs and inline parts
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I hopped through tags and sniffed each src,

spun tiny UUIDs for images on the porch,
tucked pixels into MIME hollows snug and neat,
swept orphan crumbs from the multipart street,
one glad hop — the draft and body now meet.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 67.65% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(mail): auto-resolve local image paths in draft body HTML (#81)' accurately describes the main feature: allowing automatic resolution of local image paths in HTML body when using set_body/set_reply_body operations.
Linked Issues check ✅ Passed The PR fully addresses #81 by allowing local image paths in HTML (e.g., ) that are auto-resolved to inline MIME parts with generated CIDs, eliminating the need for manual cid: references and providing the simpler, higher-level workflow requested.
Out of Scope Changes check ✅ Passed All changes are directly related to the core objective: implementing auto-resolution of local image paths in draft body HTML, including refactoring inline attachment flow, adding HTML parsing/rewriting, unified CID validation, and updating documentation to reflect the new workflow.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
shortcuts/mail/draft/patch.go (1)

913-926: uniqueCID has an unbounded loop but is practically safe.

The for i := 2; ; i++ loop theoretically runs forever if all CIDs are taken. In practice, this is impossible since the suffix space is unbounded (integers), and the set of existing CIDs is finite.

Consider adding a reasonable upper bound as defensive coding, though this is low priority.

♻️ Optional: Add upper bound for defensive coding
 func uniqueCID(baseCID string, usedCIDs map[string]bool) string {
 	if !usedCIDs[strings.ToLower(baseCID)] {
 		return baseCID
 	}
-	for i := 2; ; i++ {
+	for i := 2; i < 10000; i++ {
 		candidate := fmt.Sprintf("%s-%d", baseCID, i)
 		if !usedCIDs[strings.ToLower(candidate)] {
 			return candidate
 		}
 	}
+	// Fallback: use UUID-style suffix (extremely unlikely to reach here)
+	return fmt.Sprintf("%s-%d", baseCID, time.Now().UnixNano())
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/draft/patch.go` around lines 913 - 926, The uniqueCID function
currently uses an unbounded for loop; add a defensive upper bound by introducing
a const maxAttempts (e.g., 10000) and change the loop to for i := 2; i <=
maxAttempts; i++ checking candidates via strings.ToLower against usedCIDs; if no
unique candidate is found after maxAttempts, generate a guaranteed-unique
fallback (for example append a time-based suffix like fmt.Sprintf("%s-%d",
baseCID, time.Now().UnixNano()) or a random/UUID suffix) and return that; update
imports (time or crypto/rand/uuid) as needed and keep the function signature
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@shortcuts/mail/draft/patch.go`:
- Around line 913-926: The uniqueCID function currently uses an unbounded for
loop; add a defensive upper bound by introducing a const maxAttempts (e.g.,
10000) and change the loop to for i := 2; i <= maxAttempts; i++ checking
candidates via strings.ToLower against usedCIDs; if no unique candidate is found
after maxAttempts, generate a guaranteed-unique fallback (for example append a
time-based suffix like fmt.Sprintf("%s-%d", baseCID, time.Now().UnixNano()) or a
random/UUID suffix) and return that; update imports (time or crypto/rand/uuid)
as needed and keep the function signature unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 96129ab7-b34f-4618-905d-31fc4ff6f750

📥 Commits

Reviewing files that changed from the base of the PR and between 634adfc and a125b7e.

📒 Files selected for processing (5)
  • shortcuts/mail/draft/patch.go
  • shortcuts/mail/draft/patch_inline_resolve_test.go
  • shortcuts/mail/draft/patch_test.go
  • shortcuts/mail/mail_draft_edit.go
  • skills/lark-mail/references/lark-mail-draft-edit.md

chanthuang
chanthuang previously approved these changes Mar 31, 2026
Copy link
Copy Markdown
Collaborator

@chanthuang chanthuang left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Clean design — collapsing the two-step add_inline + set_body workflow into a single <img src="./path"> auto-resolve is a big UX win for both agents and humans.

A few notes for follow-up (non-blocking):

  1. Orphan cleanup is now silent — the old behavior errored on orphaned CIDs, the new behavior auto-removes them. Consider adding a stderr warning when parts are actually removed, so users aren't surprised if an image disappears from a draft.

  2. Regex edge cases — might be worth adding test coverage for multi-space / newline between <img and src (e.g. <img src="./a.png">).

  3. Skill reference — consider marking add_inline as a low-level op in the docs, nudging agents toward the simpler local-path approach.

Test coverage is solid (20 new cases, 82.5% package coverage). The CID hardening in the second commit (whitespace → hyphens, validation for spaces/tabs/brackets) is a good catch.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
shortcuts/mail/draft/patch_inline_resolve_test.go (1)

651-695: Consider adding a test case for single-quoted src attributes.

The imgSrcRegexp supports both single and double quotes, but the tests only use double quotes. A test case like <img src='./logo.png' /> would verify this path works correctly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/draft/patch_inline_resolve_test.go` around lines 651 - 695,
Add a test case to TestImgSrcRegexpSkipsDataSrc that verifies imgSrcRegexp
matches single-quoted src attributes (e.g. `<img src='./logo.png' />`) by adding
an entry in the tests slice with name like "single-quoted src", html set to a
single-quoted src string, and want set to the expected captured value
"./logo.png"; this ensures imgSrcRegexp (used in TestImgSrcRegexpSkipsDataSrc)
is exercised for single-quote attribute syntax.
shortcuts/mail/draft/patch.go (1)

952-978: Nested multipart/related containers are not recursively cleaned.

When the root is multipart/related, kept children are not recursively processed. If a draft contains nested multipart/related structures, orphaned inline parts inside the nested container would not be removed.

This is likely acceptable since nested multipart/related is uncommon in email drafts. Consider adding a comment noting this limitation if you want to be explicit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/draft/patch.go` around lines 952 - 978, The function
removeOrphanedInlineParts currently skips recursive processing when root is
multipart/related, so nested multipart/related containers are not cleaned;
update it so that after you build the kept slice for a multipart/related root
you iterate over kept children and call removeOrphanedInlineParts(child,
referencedCIDs) for each kept child and if any child.Dirty is true set
root.Dirty = true; keep existing behavior for non-related roots. Reference:
function removeOrphanedInlineParts, root.Children, referencedCIDs, and
root.Dirty.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@shortcuts/mail/draft/patch_inline_resolve_test.go`:
- Around line 651-695: Add a test case to TestImgSrcRegexpSkipsDataSrc that
verifies imgSrcRegexp matches single-quoted src attributes (e.g. `<img
src='./logo.png' />`) by adding an entry in the tests slice with name like
"single-quoted src", html set to a single-quoted src string, and want set to the
expected captured value "./logo.png"; this ensures imgSrcRegexp (used in
TestImgSrcRegexpSkipsDataSrc) is exercised for single-quote attribute syntax.

In `@shortcuts/mail/draft/patch.go`:
- Around line 952-978: The function removeOrphanedInlineParts currently skips
recursive processing when root is multipart/related, so nested multipart/related
containers are not cleaned; update it so that after you build the kept slice for
a multipart/related root you iterate over kept children and call
removeOrphanedInlineParts(child, referencedCIDs) for each kept child and if any
child.Dirty is true set root.Dirty = true; keep existing behavior for
non-related roots. Reference: function removeOrphanedInlineParts, root.Children,
referencedCIDs, and root.Dirty.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 57227bbf-d674-48a1-8554-e313ae1eb62c

📥 Commits

Reviewing files that changed from the base of the PR and between a125b7e and a11111b.

📒 Files selected for processing (2)
  • shortcuts/mail/draft/patch.go
  • shortcuts/mail/draft/patch_inline_resolve_test.go

chanthuang
chanthuang previously approved these changes Mar 31, 2026
infeng added 4 commits March 31, 2026 21:48
…ite#81)

Allow <img src="./local/path.png" /> in set_body/set_reply_body HTML.
Local file paths are automatically resolved into inline MIME parts with
generated CIDs, eliminating the need to manually pair add_inline with
set_body. Removing or replacing an <img> tag in the body automatically
cleans up or replaces the corresponding MIME inline part.

- Add postProcessInlineImages to unify resolve, validate, and orphan
  cleanup into a single post-processing step
- Extract loadAndAttachInline shared helper to deduplicate addInline
  and resolveLocalImgSrc logic
- Cache resolved paths so the same file is only attached once
- Use whitelist URI scheme detection instead of blacklist
- Remove dead validateInlineCIDAfterApply and
  validateOrphanedInlineCIDAfterApply functions

Closes larksuite#81
1. Fix imgSrcRegexp to skip attribute names like data-src/x-src that
   contain "src" as a suffix — only match the real src attribute.
2. Sanitize cidFromFileName to replace whitespace with hyphens,
   producing RFC-safe CID tokens (e.g. "my logo.png" → "my-logo").
3. Add CID validation in newInlinePart to reject spaces, tabs, angle
   brackets, and parentheses — fail fast instead of silently producing
   broken inline images in the sent email.
Replace filename-derived CID generation (cidFromFileName + uniqueCID)
with UUID-based generation. UUIDs contain only [0-9a-f-] characters,
eliminating all RFC compliance risks from special characters, Unicode,
or filename collisions. Same-file deduplication via pathToCID cache
is preserved — multiple <img> tags referencing the same file still
share one MIME part and one CID.
uuid.New() calls Must(NewRandom()) which panics if the random source
fails. Replace with uuid.NewRandom() and propagate the error through
resolveLocalImgSrc, so the CLI returns a clear error instead of
crashing in extreme environments.
@infeng infeng force-pushed the fix/mail-add-inline-insert-html branch from a11111b to 7f1ac95 Compare March 31, 2026 13:49
…ption

The auto-resolve PR accidentally dropped "the quote block is
re-appended automatically" from the set_reply_body shape description.
Restore it alongside the new local-path support note.
chanthuang
chanthuang previously approved these changes Mar 31, 2026
Comment thread shortcuts/mail/draft/patch.go
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@shortcuts/mail/draft/patch.go`:
- Around line 984-988: The HTML post-processing currently uses
findPart(snapshot.Body, snapshot.PrimaryHTMLPartID) which can pick the wrong
node when PrimaryHTMLPartID is empty/stale; change postProcessInlineImages to
locate the HTML node with findPrimaryBodyPart(snapshot.Body, "text/html") (the
same approach refreshSnapshot uses) so it reliably finds the actual HTML part in
DraftSnapshot regardless of PrimaryHTMLPartID, and then proceed with the
existing inline src rewriting/cleanup logic using that returned part instead of
findPart.
- Around line 883-891: The isLocalFileSrc function incorrectly treats Windows
absolute paths like "C:\..." or "C:/..." as URIs because uriSchemeRegexp matches
the "C:" prefix; update isLocalFileSrc to special-case Windows drive-letter
absolute paths before the scheme check by detecting a leading drive-letter
pattern (e.g., /^[A-Za-z]:[\\/]/) on the trimmed src and returning true for
those, while keeping the existing empty, double-slash, and uriSchemeRegexp
checks (function name: isLocalFileSrc; symbol: uriSchemeRegexp).
- Around line 964-977: The cleanup loop only removes children that are both
Content-Disposition:inline and have a Content-ID, but elsewhere any Part with a
ContentID is treated as inline; update the loop to treat parts with a non-empty
ContentID as inline (regardless of ContentDisposition) and remove them if their
CID is not referenced, while still removing parts that are
Content-Disposition:inline even if they lack a CID. Concretely, in the loop over
root.Children (the Part instances), first check if child.ContentID != "" and if
so consult referencedCIDs[strings.ToLower(child.ContentID)] and skip (and mark
root.Dirty) when unreferenced; otherwise if
strings.EqualFold(child.ContentDisposition, "inline") then mark root.Dirty and
skip; append all other children as before.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2d401403-d812-452d-b6d0-d4897f1c20c8

📥 Commits

Reviewing files that changed from the base of the PR and between a11111b and 4e52fc4.

📒 Files selected for processing (5)
  • shortcuts/mail/draft/patch.go
  • shortcuts/mail/draft/patch_inline_resolve_test.go
  • shortcuts/mail/draft/patch_test.go
  • shortcuts/mail/mail_draft_edit.go
  • skills/lark-mail/references/lark-mail-draft-edit.md
✅ Files skipped from review due to trivial changes (3)
  • skills/lark-mail/references/lark-mail-draft-edit.md
  • shortcuts/mail/mail_draft_edit.go
  • shortcuts/mail/draft/patch_inline_resolve_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • shortcuts/mail/draft/patch_test.go

Comment thread shortcuts/mail/draft/patch.go
Comment thread shortcuts/mail/draft/patch.go
Comment thread shortcuts/mail/draft/patch.go
- Add comment in postProcessInlineImages explaining that partially
  attached inline parts on error are cleaned up by the next Apply.
- Add regex test cases: single-quoted src, multiple spaces before src,
  and newline before src.
1. removeOrphanedInlineParts: change condition from
   ContentDisposition=="inline" && ContentID!="" to
   isInlinePart(child) && ContentID!="", matching the predicate used
   elsewhere — parts with only a ContentID (no Content-Disposition)
   are now correctly cleaned up.
2. postProcessInlineImages: use findPrimaryBodyPart instead of
   findPart(snapshot.Body, PrimaryHTMLPartID) to avoid stale PartID
   after ops restructure the MIME tree.
@infeng infeng added domain/mail PR touches the mail domain enhancement New feature or request labels Mar 31, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (3)
shortcuts/mail/draft/patch.go (3)

883-891: ⚠️ Potential issue | 🟠 Major

Handle Windows drive-letter paths before the scheme check.

uriSchemeRegexp matches the C: prefix, so C:\foo.png and C:/foo.png are treated as URI schemes and never go through local-path resolution. That breaks the new workflow on Windows whenever callers use an absolute path.

Suggested fix
 func isLocalFileSrc(src string) bool {
 	trimmed := strings.TrimSpace(src)
 	if trimmed == "" {
 		return false
 	}
+	if len(trimmed) >= 3 &&
+		((trimmed[0] >= 'A' && trimmed[0] <= 'Z') || (trimmed[0] >= 'a' && trimmed[0] <= 'z')) &&
+		trimmed[1] == ':' &&
+		(trimmed[2] == '\\' || trimmed[2] == '/') {
+		return true
+	}
 	if strings.HasPrefix(trimmed, "//") {
 		return false
 	}
 	return !uriSchemeRegexp.MatchString(trimmed)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/draft/patch.go` around lines 883 - 891, The function
isLocalFileSrc incorrectly treats Windows absolute paths like "C:\foo" as URIs
because uriSchemeRegexp matches the "C:" prefix; update isLocalFileSrc to detect
Windows drive-letter absolute paths before the scheme check (e.g., test trimmed
against a drive-letter pattern like /^[A-Za-z]:[\\/]/) and return true for those
cases so they are treated as local files, then proceed with the existing
uriSchemeRegexp.MatchString(trimmed) check for other schemes.

964-977: ⚠️ Potential issue | 🟠 Major

Use the same inline predicate during orphan cleanup.

This sweep only drops children that are both Content-Disposition:inline and Content-ID-bearing. Elsewhere in this file, any part with a CID is already treated as inline, so CID-only related images from existing drafts survive cleanup after their <img> disappears. Inline parts without a CID also stay around here even though they can’t be referenced from HTML.

Suggested fix
 	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)] {
+		cid := strings.ToLower(strings.Trim(strings.TrimSpace(child.ContentID), "<>"))
+		if cid != "" {
+			if !referencedCIDs[cid] {
+				root.Dirty = true
+				continue
+			}
+		} else if strings.EqualFold(child.ContentDisposition, "inline") {
 				root.Dirty = true
 				continue
-			}
 		}
 		kept = append(kept, child)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/draft/patch.go` around lines 964 - 977, The orphan cleanup
loop over root.Children currently only treats parts as inline when
ContentDisposition == "inline" and ContentID != "", which mismatches the rest of
the file where any part with a CID is treated as inline; update the loop in
shortcuts/mail/draft/patch.go to use the same inline predicate (i.e., consider a
part inline if ContentID != "" OR ContentDisposition equals "inline") and then:
if the part is inline but either has no ContentID or its lowercased ContentID is
not in referencedCIDs, mark root.Dirty and skip it; otherwise keep the part
(update references to ContentID, ContentDisposition, referencedCIDs, and
root.Children accordingly).

984-988: ⚠️ Potential issue | 🟠 Major

Find the HTML part from the current tree, not PrimaryHTMLPartID.

postProcessInlineImages now runs before refreshSnapshot, so snapshot.PrimaryHTMLPartID can still be empty or stale when this apply pass creates or rewraps the HTML subtree. In that state, findPart(snapshot.Body, snapshot.PrimaryHTMLPartID) can pick the wrong unindexed node or none at all, which skips local src rewriting and can orphan-clean the wrong related parts.

Suggested fix
 func postProcessInlineImages(snapshot *DraftSnapshot) error {
-	htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
+	htmlPart := findPrimaryBodyPart(snapshot.Body, "text/html")
 	if htmlPart == nil {
 		return nil
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/draft/patch.go` around lines 984 - 988,
postProcessInlineImages should not rely on snapshot.PrimaryHTMLPartID since it
can be empty/stale before refreshSnapshot; update postProcessInlineImages to
locate the HTML part by searching the current tree (snapshot.Body) for a part
whose ContentType/IsHTML indicates "text/html" (e.g., a helper like
findPartByContentType or a traversal that checks part.MIME/ContentType) instead
of calling findPart(snapshot.Body, snapshot.PrimaryHTMLPartID); use that
discovered part for inline image rewriting and related-part cleanup so the
function operates on the actual HTML node present in the snapshot tree.
🧹 Nitpick comments (1)
shortcuts/mail/draft/patch.go (1)

763-781: Share the stricter CID validator with replaceInline.

The new character check only runs through newInlinePart; replaceInline still trims <> and rejects only CR/LF before assigning part.ContentID. That leaves replace_inline able to emit invalid Content-ID values while add_inline and local-path resolution reject the same input.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/draft/patch.go` around lines 763 - 781, The CID validation
logic in newInlinePart is stricter than replaceInline, allowing replaceInline to
set invalid Content-ID values; extract the CID trimming and validation into a
shared helper (e.g., validateInlineCID or normalizeInlineCID) and call it from
both newInlinePart and replaceInline before assigning part.ContentID; the helper
should perform trimming of "<>", check for empty, call validate.RejectCRLF, and
enforce the strings.ContainsAny(... " \t<>()") rule so both newInlinePart and
replaceInline use identical validation and reject invalid CIDs consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@shortcuts/mail/draft/patch.go`:
- Around line 883-891: The function isLocalFileSrc incorrectly treats Windows
absolute paths like "C:\foo" as URIs because uriSchemeRegexp matches the "C:"
prefix; update isLocalFileSrc to detect Windows drive-letter absolute paths
before the scheme check (e.g., test trimmed against a drive-letter pattern like
/^[A-Za-z]:[\\/]/) and return true for those cases so they are treated as local
files, then proceed with the existing uriSchemeRegexp.MatchString(trimmed) check
for other schemes.
- Around line 964-977: The orphan cleanup loop over root.Children currently only
treats parts as inline when ContentDisposition == "inline" and ContentID != "",
which mismatches the rest of the file where any part with a CID is treated as
inline; update the loop in shortcuts/mail/draft/patch.go to use the same inline
predicate (i.e., consider a part inline if ContentID != "" OR ContentDisposition
equals "inline") and then: if the part is inline but either has no ContentID or
its lowercased ContentID is not in referencedCIDs, mark root.Dirty and skip it;
otherwise keep the part (update references to ContentID, ContentDisposition,
referencedCIDs, and root.Children accordingly).
- Around line 984-988: postProcessInlineImages should not rely on
snapshot.PrimaryHTMLPartID since it can be empty/stale before refreshSnapshot;
update postProcessInlineImages to locate the HTML part by searching the current
tree (snapshot.Body) for a part whose ContentType/IsHTML indicates "text/html"
(e.g., a helper like findPartByContentType or a traversal that checks
part.MIME/ContentType) instead of calling findPart(snapshot.Body,
snapshot.PrimaryHTMLPartID); use that discovered part for inline image rewriting
and related-part cleanup so the function operates on the actual HTML node
present in the snapshot tree.

---

Nitpick comments:
In `@shortcuts/mail/draft/patch.go`:
- Around line 763-781: The CID validation logic in newInlinePart is stricter
than replaceInline, allowing replaceInline to set invalid Content-ID values;
extract the CID trimming and validation into a shared helper (e.g.,
validateInlineCID or normalizeInlineCID) and call it from both newInlinePart and
replaceInline before assigning part.ContentID; the helper should perform
trimming of "<>", check for empty, call validate.RejectCRLF, and enforce the
strings.ContainsAny(... " \t<>()") rule so both newInlinePart and replaceInline
use identical validation and reject invalid CIDs consistently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6db1e3a3-c5ac-4603-92ee-7dbb0e601765

📥 Commits

Reviewing files that changed from the base of the PR and between 4e52fc4 and c2d1370.

📒 Files selected for processing (2)
  • shortcuts/mail/draft/patch.go
  • shortcuts/mail/draft/patch_inline_resolve_test.go

…ct HTML body

The previous change (d3d1982) broadened the orphan cleanup predicate to
isInlinePart(), which treats any part with a ContentID as inline. This
deletes the primary HTML body when it carries a Content-ID header
(valid in multipart/related), even on metadata-only edits like
set_subject.

Revert to the original ContentDisposition=="inline" && ContentID!=""
condition — only parts explicitly marked as inline attachments are
candidates for orphan removal. Add regression test covering
multipart/related with a Content-ID-bearing HTML body.
@infeng infeng merged commit 70c72a2 into larksuite:main Apr 1, 2026
7 of 11 checks passed
infeng added a commit to infeng/cli that referenced this pull request Apr 1, 2026
infeng added a commit that referenced this pull request Apr 1, 2026
* Revert "fix(mail): clarify that file path flags only accept relative paths (#141)"

This reverts commit 1ffe870.

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

This reverts commit 70c72a2.

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

This reverts commit d465e08.
infeng added a commit to infeng/cli that referenced this pull request Apr 2, 2026
…ite#81) (larksuite#139)

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

Allow <img src="./local/path.png" /> in set_body/set_reply_body HTML.
Local file paths are automatically resolved into inline MIME parts with
generated CIDs, eliminating the need to manually pair add_inline with
set_body. Removing or replacing an <img> tag in the body automatically
cleans up or replaces the corresponding MIME inline part.

- Add postProcessInlineImages to unify resolve, validate, and orphan
  cleanup into a single post-processing step
- Extract loadAndAttachInline shared helper to deduplicate addInline
  and resolveLocalImgSrc logic
- Cache resolved paths so the same file is only attached once
- Use whitelist URI scheme detection instead of blacklist
- Remove dead validateInlineCIDAfterApply and
  validateOrphanedInlineCIDAfterApply functions

Closes larksuite#81

* fix(mail): harden inline image CID handling

1. Fix imgSrcRegexp to skip attribute names like data-src/x-src that
   contain "src" as a suffix — only match the real src attribute.
2. Sanitize cidFromFileName to replace whitespace with hyphens,
   producing RFC-safe CID tokens (e.g. "my logo.png" → "my-logo").
3. Add CID validation in newInlinePart to reject spaces, tabs, angle
   brackets, and parentheses — fail fast instead of silently producing
   broken inline images in the sent email.

* refactor(mail): use UUID for auto-generated inline CIDs

Replace filename-derived CID generation (cidFromFileName + uniqueCID)
with UUID-based generation. UUIDs contain only [0-9a-f-] characters,
eliminating all RFC compliance risks from special characters, Unicode,
or filename collisions. Same-file deduplication via pathToCID cache
is preserved — multiple <img> tags referencing the same file still
share one MIME part and one CID.

* fix(mail): avoid panic in generateCID by using uuid.NewRandom

uuid.New() calls Must(NewRandom()) which panics if the random source
fails. Replace with uuid.NewRandom() and propagate the error through
resolveLocalImgSrc, so the CLI returns a clear error instead of
crashing in extreme environments.

* fix(mail): restore quote block hint in set_reply_body template description

The auto-resolve PR accidentally dropped "the quote block is
re-appended automatically" from the set_reply_body shape description.
Restore it alongside the new local-path support note.

* fix(mail): add orphan invariant comment and expand regex test coverage

- Add comment in postProcessInlineImages explaining that partially
  attached inline parts on error are cleaned up by the next Apply.
- Add regex test cases: single-quoted src, multiple spaces before src,
  and newline before src.

* fix(mail): use consistent inline predicate and safer HTML part lookup

1. removeOrphanedInlineParts: change condition from
   ContentDisposition=="inline" && ContentID!="" to
   isInlinePart(child) && ContentID!="", matching the predicate used
   elsewhere — parts with only a ContentID (no Content-Disposition)
   are now correctly cleaned up.
2. postProcessInlineImages: use findPrimaryBodyPart instead of
   findPart(snapshot.Body, PrimaryHTMLPartID) to avoid stale PartID
   after ops restructure the MIME tree.

* fix(mail): revert orphan cleanup to ContentDisposition check to protect HTML body

The previous change (d3d1982) broadened the orphan cleanup predicate to
isInlinePart(), which treats any part with a ContentID as inline. This
deletes the primary HTML body when it carries a Content-ID header
(valid in multipart/related), even on metadata-only edits like
set_subject.

Revert to the original ContentDisposition=="inline" && ContentID!=""
condition — only parts explicitly marked as inline attachments are
candidates for orphan removal. Add regression test covering
multipart/related with a Content-ID-bearing HTML body.
@infeng infeng deleted the fix/mail-add-inline-insert-html branch April 2, 2026 03:07
tuxedomm pushed a commit that referenced this pull request Apr 3, 2026
…139)

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

Allow <img src="./local/path.png" /> in set_body/set_reply_body HTML.
Local file paths are automatically resolved into inline MIME parts with
generated CIDs, eliminating the need to manually pair add_inline with
set_body. Removing or replacing an <img> tag in the body automatically
cleans up or replaces the corresponding MIME inline part.

- Add postProcessInlineImages to unify resolve, validate, and orphan
  cleanup into a single post-processing step
- Extract loadAndAttachInline shared helper to deduplicate addInline
  and resolveLocalImgSrc logic
- Cache resolved paths so the same file is only attached once
- Use whitelist URI scheme detection instead of blacklist
- Remove dead validateInlineCIDAfterApply and
  validateOrphanedInlineCIDAfterApply functions

Closes #81

* fix(mail): harden inline image CID handling

1. Fix imgSrcRegexp to skip attribute names like data-src/x-src that
   contain "src" as a suffix — only match the real src attribute.
2. Sanitize cidFromFileName to replace whitespace with hyphens,
   producing RFC-safe CID tokens (e.g. "my logo.png" → "my-logo").
3. Add CID validation in newInlinePart to reject spaces, tabs, angle
   brackets, and parentheses — fail fast instead of silently producing
   broken inline images in the sent email.

* refactor(mail): use UUID for auto-generated inline CIDs

Replace filename-derived CID generation (cidFromFileName + uniqueCID)
with UUID-based generation. UUIDs contain only [0-9a-f-] characters,
eliminating all RFC compliance risks from special characters, Unicode,
or filename collisions. Same-file deduplication via pathToCID cache
is preserved — multiple <img> tags referencing the same file still
share one MIME part and one CID.

* fix(mail): avoid panic in generateCID by using uuid.NewRandom

uuid.New() calls Must(NewRandom()) which panics if the random source
fails. Replace with uuid.NewRandom() and propagate the error through
resolveLocalImgSrc, so the CLI returns a clear error instead of
crashing in extreme environments.

* fix(mail): restore quote block hint in set_reply_body template description

The auto-resolve PR accidentally dropped "the quote block is
re-appended automatically" from the set_reply_body shape description.
Restore it alongside the new local-path support note.

* fix(mail): add orphan invariant comment and expand regex test coverage

- Add comment in postProcessInlineImages explaining that partially
  attached inline parts on error are cleaned up by the next Apply.
- Add regex test cases: single-quoted src, multiple spaces before src,
  and newline before src.

* fix(mail): use consistent inline predicate and safer HTML part lookup

1. removeOrphanedInlineParts: change condition from
   ContentDisposition=="inline" && ContentID!="" to
   isInlinePart(child) && ContentID!="", matching the predicate used
   elsewhere — parts with only a ContentID (no Content-Disposition)
   are now correctly cleaned up.
2. postProcessInlineImages: use findPrimaryBodyPart instead of
   findPart(snapshot.Body, PrimaryHTMLPartID) to avoid stale PartID
   after ops restructure the MIME tree.

* fix(mail): revert orphan cleanup to ContentDisposition check to protect HTML body

The previous change (d3d1982) broadened the orphan cleanup predicate to
isInlinePart(), which treats any part with a ContentID as inline. This
deletes the primary HTML body when it carries a Content-ID header
(valid in multipart/related), even on metadata-only edits like
set_subject.

Revert to the original ContentDisposition=="inline" && ContentID!=""
condition — only parts explicitly marked as inline attachments are
candidates for orphan removal. Add regression test covering
multipart/related with a Content-ID-bearing HTML body.
tuxedomm pushed a commit that referenced this pull request Apr 3, 2026
* Revert "fix(mail): clarify that file path flags only accept relative paths (#141)"

This reverts commit eac6fa9.

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

This reverts commit 22cc882.

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

This reverts commit d465e08.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain/mail PR touches the mail domain enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mail +draft-edit: add_inline does not actually insert inline image into HTML body

2 participants