Files
larksuite-cli/shortcuts/mail/draft/patch.go
chanthuang dce2beb91c feat(mail): support calendar events in emails (#646)
* feat(ics): add RFC 5545 iCalendar generator and parser

Add shortcuts/mail/ics package:
- builder.go: generates METHOD:REQUEST ICS with VEVENT, ORGANIZER,
  ATTENDEE, DTSTART/DTEND with timezone, UID, and X-LARK-MAIL-DRAFT
- parser.go: parses ICS into ParsedEvent struct, detects IsLarkDraft
- Handles CN quoting, control-char sanitization, email validation,
  line folding per RFC 5545, and TZID edge cases

Change-Id: I01d13285a57a5a4de50891c54d655efa8423c3c1

* feat(mail): support calendar events in emails

- Add --event-summary/start/end/location flags to +send, +reply,
  +reply-all, +forward, +draft-create
- Build ICS and embed as text/calendar in multipart/alternative
- Validate event time range and enforce --event/--send-time mutual
  exclusion (extracted into validateEventSendTimeExclusion)
- CalendarBody() in emlbuilder places ICS correctly
- Exclude BCC from ATTENDEE list

Change-Id: Icf9e49ababebc4e8fcf36760ab613c64938c2744

* feat(mail): X-LARK-MAIL-DRAFT and read-only calendar guard

- ics.Build() writes X-LARK-MAIL-DRAFT:TRUE so Feishu client
  recognizes CLI-created calendar events as editable
- ics.ParseEvent() detects IsLarkDraft field
- +draft-edit rejects --set-event-* on calendars without
  X-LARK-MAIL-DRAFT marker (read-only after send)
- Export FindPartByMediaType from draft package for cross-package use
- Add set_calendar/remove_calendar patch ops with full test coverage

Change-Id: I7d547a4b40880e8d4ee3fecf68864d6ea89e66cd

* feat(mail): forward preserves original calendar ICS

When forwarding an email that contains a calendar event (body_calendar),
pass through the original ICS bytes as text/calendar part if no new
--event-* flags are specified.

Change-Id: I67d2e82604eaf969cee8c7e0bedcf32198d12d57

* docs(mail): document calendar invitation feature

- Add --event-* params to +send, +reply, +reply-all, +forward,
  +draft-create, +draft-edit reference docs
- Add calendar_event output section to +message reference
- Add calendar invitation workflow to skill-template/domains/mail.md
- Regenerate SKILL.md via gen-skills

Change-Id: Iccacd06990d91e1cf3beb896d5b772d27e5e29ff

* fix(mail): reject --set-event-start/end/location without --set-event-summary

Change-Id: Icb651ff28ede526ff96b22e7b304b7bdea86d01f
Co-Authored-By: AI

* fix(mail): include --event-location in validateEventFlags; fix stale comment

Change-Id: I2f47016b6bfa11957dfe2c8c499cf36737efba53
Co-Authored-By: AI

* fix(mail): clear stale headers when wrapping single-leaf body in multipart/alternative

Change-Id: I29fe883c9151570f7939d372523b128cbea0b1ed
Co-Authored-By: AI

* fix(mail): add method=REQUEST to text/calendar MIME part created by set_calendar

Change-Id: I4d23674e20e4c42adab36385ff5ee8bb6d97625d
Co-Authored-By: AI

* fix(mail): use post-edit recipients for ICS attendees when --set-to combined with --set-event-*

Change-Id: I659e06635dd043f798d2f2e90d7dbca6e13d7f3d
Co-Authored-By: AI

* fix(mail): cover add_recipient/remove_recipient in ICS attendee resolution

Extract effectiveRecipients() to replay all three recipient op types
(set_recipients, add_recipient, remove_recipient) before building the
ICS for set_calendar, so patch-file recipient changes are reflected in
ATTENDEE metadata.

Change-Id: I3a7a55f96df8fac7d924a4dbeedd5b3d0d9d443c
Co-Authored-By: AI

* fix(mail): derive method= from ICS body in writeCalendarPart instead of hardcoding REQUEST

Passthrough ICS (e.g. forwarded METHOD:CANCEL) previously emitted a
Content-Type with method=REQUEST, disagreeing with the body. Now
extractICSMethod() scans the ICS for METHOD: and falls back to REQUEST
when absent, keeping existing behavior for our own generated ICS.

Change-Id: I4bf6c3755a189a436c2d26b082372d9f838f4051
Co-Authored-By: AI

* fix(mail): normalize calendar_event start/end to UTC in output

Callers expect RFC 3339 UTC strings; source ICS with TZID offsets
previously emitted +08:00 instead of Z.

Change-Id: I88bd4b925f8fc3b4f569e41712ae58ab50d94a2f
Co-Authored-By: AI

* fix(mail): make ICS parser case-insensitive and handle parameterized property names

RFC 5545 §3.1 allows any case and optional parameters on all property
names. Unify UID/SUMMARY/LOCATION/DTSTART/etc. to compare via
strings.ToUpper(name) and add HasPrefix checks for the NAME; form,
consistent with how ORGANIZER and ATTENDEE were already handled.

Change-Id: I7dc642dd210a3256f2189a901a2d9518ea284815
Co-Authored-By: AI
2026-04-29 15:31:38 +08:00

1375 lines
42 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package draft
import (
"fmt"
"io"
"mime"
"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,
"content-type": true,
"content-transfer-encoding": true,
"in-reply-to": true,
"references": true,
"reply-to": true,
}
// bodyChangingOps lists patch operations that modify the HTML body content,
// which is the trigger for running local image path resolution.
var bodyChangingOps = map[string]bool{
"set_body": true,
"set_reply_body": true,
"replace_body": true,
"append_body": true,
"insert_signature": true,
"remove_signature": true,
}
func Apply(dctx *DraftCtx, snapshot *DraftSnapshot, patch Patch) error {
if err := patch.Validate(); err != nil {
return err
}
hasBodyChange := false
for _, op := range patch.Ops {
if err := applyOp(dctx, snapshot, op, patch.Options); err != nil {
return err
}
if bodyChangingOps[op.Op] {
hasBodyChange = true
}
}
if err := postProcessInlineImages(dctx, snapshot, hasBodyChange); err != nil {
return err
}
return refreshSnapshot(snapshot)
}
func applyOp(dctx *DraftCtx, snapshot *DraftSnapshot, op PatchOp, options PatchOptions) error {
switch op.Op {
case "set_subject":
if strings.ContainsAny(op.Value, "\r\n") {
return fmt.Errorf("set_subject: value must not contain CR or LF")
}
upsertHeader(&snapshot.Headers, "Subject", op.Value)
case "set_recipients":
return setRecipients(snapshot, op.Field, op.Addresses)
case "add_recipient":
return addRecipient(snapshot, op.Field, Address{Name: op.Name, Address: op.Address})
case "remove_recipient":
return removeRecipient(snapshot, op.Field, op.Address)
case "set_reply_to":
upsertHeader(&snapshot.Headers, "Reply-To", formatAddressList(op.Addresses))
case "clear_reply_to":
removeHeader(&snapshot.Headers, "Reply-To")
case "set_body":
return setBody(snapshot, op.Value, options)
case "set_reply_body":
return setReplyBody(snapshot, op.Value, options)
case "replace_body":
return replaceBody(snapshot, op.BodyKind, op.Value, options)
case "append_body":
return appendBody(snapshot, op.BodyKind, op.Value, options)
case "set_header":
if err := ensureHeaderEditable(op.Name, options); err != nil {
return err
}
if strings.ContainsAny(op.Name, ":\r\n") {
return fmt.Errorf("set_header: header name must not contain ':', CR, or LF")
}
if strings.ContainsAny(op.Value, "\r\n") {
return fmt.Errorf("set_header: header value must not contain CR or LF")
}
upsertHeader(&snapshot.Headers, op.Name, op.Value)
case "remove_header":
if err := ensureHeaderEditable(op.Name, options); err != nil {
return err
}
removeHeader(&snapshot.Headers, op.Name)
case "add_attachment":
return addAttachment(dctx, snapshot, op.Path)
case "remove_attachment":
// Priority: part_id > cid > token. When only token is set, route to
// the large attachment path (updates header + HTML card, no MIME
// part to remove). Otherwise, resolve to a concrete part_id.
tgt := op.Target
if strings.TrimSpace(tgt.PartID) == "" && strings.TrimSpace(tgt.CID) == "" {
if token := strings.TrimSpace(tgt.Token); token != "" {
return removeLargeAttachment(snapshot, token)
}
}
partID, err := resolveTarget(snapshot, tgt)
if err != nil {
return fmt.Errorf("remove_attachment: %w", err)
}
return removeAttachment(snapshot, partID)
case "add_inline":
return addInline(dctx, snapshot, op.Path, op.CID, op.FileName, op.ContentType)
case "replace_inline":
partID, err := resolveTarget(snapshot, op.Target)
if err != nil {
return fmt.Errorf("replace_inline: %w", err)
}
return replaceInline(dctx, snapshot, partID, op.Path, op.CID, op.FileName, op.ContentType)
case "remove_inline":
partID, err := resolveTarget(snapshot, op.Target)
if err != nil {
return fmt.Errorf("remove_inline: %w", err)
}
return removeInline(snapshot, partID)
case "insert_signature":
return insertSignatureOp(snapshot, op)
case "remove_signature":
return removeSignatureOp(snapshot)
case "set_calendar":
return applyCalendarSet(snapshot, op.CalendarICS)
case "remove_calendar":
return applyCalendarRemove(snapshot)
default:
return fmt.Errorf("unsupported patch op %q", op.Op)
}
return nil
}
func ensureHeaderEditable(name string, options PatchOptions) error {
if protectedHeaders[strings.ToLower(strings.TrimSpace(name))] && !options.AllowProtectedHeaderEdits {
return fmt.Errorf("header %q is protected; rerun with allow_protected_header_edits", name)
}
return nil
}
func setRecipients(snapshot *DraftSnapshot, field string, addrs []Address) error {
field = strings.ToLower(strings.TrimSpace(field))
if !isRecipientField(field) {
return fmt.Errorf("recipient field must be one of to/cc/bcc")
}
normalized := make([]Address, 0, len(addrs))
seen := map[string]bool{}
for _, addr := range addrs {
if strings.TrimSpace(addr.Address) == "" {
return fmt.Errorf("recipient address is empty")
}
key := strings.ToLower(strings.TrimSpace(addr.Address))
if seen[key] {
continue
}
seen[key] = true
normalized = append(normalized, Address{
Name: addr.Name,
Address: strings.TrimSpace(addr.Address),
})
}
_, headerName := recipientField(snapshot, field)
setRecipientField(snapshot, headerName, normalized)
return nil
}
func addRecipient(snapshot *DraftSnapshot, field string, addr Address) error {
if strings.TrimSpace(addr.Address) == "" {
return fmt.Errorf("recipient address is empty")
}
field = strings.ToLower(strings.TrimSpace(field))
addrs, headerName := recipientField(snapshot, field)
key := strings.ToLower(strings.TrimSpace(addr.Address))
seen := false
for _, existing := range addrs {
if strings.EqualFold(existing.Address, key) || strings.EqualFold(existing.Address, addr.Address) {
seen = true
break
}
}
if !seen {
addrs = append(addrs, addr)
}
setRecipientField(snapshot, headerName, addrs)
return nil
}
func removeRecipient(snapshot *DraftSnapshot, field, address string) error {
field = strings.ToLower(strings.TrimSpace(field))
addrs, headerName := recipientField(snapshot, field)
if len(addrs) == 0 {
return fmt.Errorf("%s header is empty", headerName)
}
needle := strings.ToLower(strings.TrimSpace(address))
next := make([]Address, 0, len(addrs))
removed := false
for _, addr := range addrs {
if strings.EqualFold(strings.TrimSpace(addr.Address), needle) {
removed = true
continue
}
next = append(next, addr)
}
if !removed {
return fmt.Errorf("recipient %q not found in %s", address, headerName)
}
setRecipientField(snapshot, headerName, next)
return nil
}
func recipientField(snapshot *DraftSnapshot, field string) ([]Address, string) {
switch field {
case "to":
return append([]Address{}, snapshot.To...), "To"
case "cc":
return append([]Address{}, snapshot.Cc...), "Cc"
case "bcc":
return append([]Address{}, snapshot.Bcc...), "Bcc"
default:
return nil, ""
}
}
func setRecipientField(snapshot *DraftSnapshot, headerName string, addrs []Address) {
if len(addrs) == 0 {
removeHeader(&snapshot.Headers, headerName)
return
}
upsertHeader(&snapshot.Headers, headerName, formatAddressList(addrs))
}
func replaceBody(snapshot *DraftSnapshot, bodyKind, value string, options PatchOptions) error {
if hasCoupledBodySummary(snapshot) {
return fmt.Errorf("draft has coupled text/plain summary and text/html body; edit them together with set_body")
}
part, err := bodyPartForKind(snapshot, bodyKind, options.RewriteEntireDraft)
if err != nil {
return err
}
part.Body = []byte(value)
part.Dirty = true
return nil
}
func appendBody(snapshot *DraftSnapshot, bodyKind, value string, options PatchOptions) error {
if hasCoupledBodySummary(snapshot) {
return fmt.Errorf("draft has coupled text/plain summary and text/html body; edit them together with set_body")
}
part, err := bodyPartForKind(snapshot, bodyKind, options.RewriteEntireDraft)
if err != nil {
return err
}
part.Body = append(part.Body, []byte(value)...)
part.Dirty = true
return nil
}
// setBody replaces the body with value. Before replacement, it
// automatically preserves system-managed elements (signature block and
// large attachment card) from the old body, so body edits do not
// accidentally delete content the user didn't author. Users can still
// replace these elements explicitly by including their own equivalents
// in the new value; they can delete them explicitly via the dedicated
// ops (remove_signature, remove_attachment).
//
// This mirrors how normal attachments (independent MIME parts) survive
// body edits — giving consistent mental model: attachments and signature
// are draft-level concerns, not body content.
func setBody(snapshot *DraftSnapshot, value string, options PatchOptions) error {
value = autoPreserveSystemManagedRegions(snapshot, value)
switch {
case snapshot.PrimaryTextPartID != "" && snapshot.PrimaryHTMLPartID == "":
return replaceBody(snapshot, "text/plain", value, options)
case snapshot.PrimaryTextPartID == "" && snapshot.PrimaryHTMLPartID != "":
return replaceBody(snapshot, "text/html", value, options)
case snapshot.PrimaryTextPartID != "" && snapshot.PrimaryHTMLPartID != "":
if err := coupledBodySetBodyInputError(snapshot, value); err != nil {
return err
}
if tryApplyCoupledBodySetBody(snapshot, value) {
return nil
}
return fmt.Errorf("draft has both text/plain and text/html body parts, but they are not a supported summary+html pair")
default:
return fmt.Errorf("draft has no unique primary body part; use replace_body with body_kind")
}
}
// autoPreserveSystemManagedRegions extracts system-managed elements
// (signature block and large attachment card) from the draft's old HTML
// body and injects them into value (before any quote block in value, or
// appended when no quote). Order is [sig][card], matching compose-time
// layout [user][sig][card][quote].
//
// For each element, auto-injection is skipped when value's
// user-authored region (before any quote block in value) already
// contains that element — so users who explicitly reconstruct the body
// with their own signature / card are respected. Elements inside a
// quote block in value belong to the quoted original message and are
// ignored for this check.
//
// No-op when the draft has no HTML body, or neither element exists in
// the old body.
func autoPreserveSystemManagedRegions(snapshot *DraftSnapshot, value string) string {
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if htmlPart == nil {
return value
}
oldHTML := string(htmlPart.Body)
sig := ExtractSignatureBlock(oldHTML)
_, card, _ := SplitAtLargeAttachment(oldHTML)
if sig == "" && card == "" {
return value
}
valuePreQuote, _ := SplitAtQuote(value)
if sig != "" && signatureWrapperRe.MatchString(valuePreQuote) {
sig = ""
}
if card != "" && HTMLContainsLargeAttachment(valuePreQuote) {
card = ""
}
if sig == "" && card == "" {
return value
}
return InsertBeforeQuoteOrAppend(value, sig+card)
}
// setReplyBody replaces only the user-authored portion of the HTML
// body, preserving the trailing reply/forward quote block (generated
// by +reply / +forward). Signature and large attachment card
// preservation is delegated to setBody, which handles them via
// autoPreserveSystemManagedRegions. When there is no quote block, this
// falls through to setBody with no quote to preserve.
func setReplyBody(snapshot *DraftSnapshot, value string, options PatchOptions) error {
htmlPartID := snapshot.PrimaryHTMLPartID
if htmlPartID == "" {
return setBody(snapshot, value, options)
}
htmlPart := findPart(snapshot.Body, htmlPartID)
if htmlPart == nil {
return setBody(snapshot, value, options)
}
_, quote := SplitAtQuote(string(htmlPart.Body))
if quote == "" {
return setBody(snapshot, value, options)
}
// setBody's autoPreserve will insert the card before the quote wrapper
// it finds inside value (which is the quote we just appended here).
return setBody(snapshot, value+quote, options)
}
func tryApplyCoupledBodySetBody(snapshot *DraftSnapshot, value string) bool {
textPart := findPart(snapshot.Body, snapshot.PrimaryTextPartID)
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if textPart == nil || htmlPart == nil {
return false
}
if !strings.EqualFold(textPart.MediaType, "text/plain") || !strings.EqualFold(htmlPart.MediaType, "text/html") {
return false
}
htmlPart.Body = []byte(value)
htmlPart.Dirty = true
textPart.Body = []byte(plainTextFromHTML(value))
textPart.Dirty = true
return true
}
func hasCoupledBodySummary(snapshot *DraftSnapshot) bool {
if snapshot == nil {
return false
}
textPart := findPart(snapshot.Body, snapshot.PrimaryTextPartID)
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if textPart == nil || htmlPart == nil {
return false
}
return strings.EqualFold(textPart.MediaType, "text/plain") && strings.EqualFold(htmlPart.MediaType, "text/html")
}
func coupledBodySetBodyInputError(snapshot *DraftSnapshot, value string) error {
if !hasCoupledBodySummary(snapshot) {
return nil
}
if bodyLooksLikeHTML(value) {
return nil
}
return fmt.Errorf("draft main body is text/html and text/plain is only its summary; set_body requires HTML input for this draft")
}
func bodyPartForKind(snapshot *DraftSnapshot, bodyKind string, allowRewrite bool) (*Part, error) {
var partID string
switch strings.ToLower(bodyKind) {
case "text/plain":
partID = snapshot.PrimaryTextPartID
case "text/html":
partID = snapshot.PrimaryHTMLPartID
default:
return nil, fmt.Errorf("unsupported body kind %q", bodyKind)
}
if partID == "" {
if !allowRewrite {
return nil, fmt.Errorf("draft has no primary %s body part", bodyKind)
}
return ensureBodyPart(snapshot, bodyKind)
}
part := findPart(snapshot.Body, partID)
if part == nil {
return nil, fmt.Errorf("body part %s not found", partID)
}
return part, nil
}
func ensureBodyPart(snapshot *DraftSnapshot, bodyKind string) (*Part, error) {
partRef := primaryBodyRootRef(&snapshot.Body)
if partRef == nil {
return nil, fmt.Errorf("draft has no primary body container")
}
return ensureBodyPartRef(partRef, bodyKind)
}
func primaryBodyRootRef(root **Part) **Part {
if root == nil || *root == nil {
return root
}
part := *root
if strings.EqualFold(part.MediaType, "multipart/mixed") {
for idx := range part.Children {
child := part.Children[idx]
if child == nil || strings.EqualFold(child.ContentDisposition, "attachment") {
continue
}
return &part.Children[idx]
}
if len(part.Children) == 0 {
part.Children = append(part.Children, nil)
return &part.Children[0]
}
}
return root
}
func ensureBodyPartRef(partRef **Part, bodyKind string) (*Part, error) {
if partRef == nil {
return nil, fmt.Errorf("body container is nil")
}
if *partRef == nil {
leaf := newBodyLeaf(bodyKind)
leaf.Dirty = true
*partRef = leaf
return leaf, nil
}
part := *partRef
if !part.IsMultipart() {
if strings.EqualFold(part.MediaType, bodyKind) {
return part, nil
}
if !isBodyKind(part.MediaType) {
return nil, fmt.Errorf("cannot rewrite non-body media type %q", part.MediaType)
}
newLeaf := newBodyLeaf(bodyKind)
alt := newMultipartContainer("multipart/alternative")
if strings.EqualFold(part.MediaType, "text/plain") {
alt.Children = []*Part{part, newLeaf}
} else {
alt.Children = []*Part{newLeaf, part}
}
alt.Dirty = true
newLeaf.Dirty = true
*partRef = alt
return newLeaf, nil
}
switch strings.ToLower(part.MediaType) {
case "multipart/alternative":
for _, child := range part.Children {
if child != nil && strings.EqualFold(child.MediaType, bodyKind) {
return child, nil
}
}
newLeaf := newBodyLeaf(bodyKind)
if strings.EqualFold(bodyKind, "text/plain") {
part.Children = append([]*Part{newLeaf}, part.Children...)
} else {
part.Children = append(part.Children, newLeaf)
}
part.Dirty = true
newLeaf.Dirty = true
return newLeaf, nil
case "multipart/related":
for idx := range part.Children {
child := part.Children[idx]
if child == nil {
continue
}
if child.IsMultipart() && strings.EqualFold(child.MediaType, "multipart/alternative") {
return ensureBodyPartRef(&part.Children[idx], bodyKind)
}
}
if len(part.Children) == 0 {
leaf := newBodyLeaf(bodyKind)
part.Children = append(part.Children, leaf)
part.Dirty = true
leaf.Dirty = true
return leaf, nil
}
return ensureBodyPartRef(&part.Children[0], bodyKind)
default:
return nil, fmt.Errorf("rewrite_entire_draft cannot synthesize body inside %q", part.MediaType)
}
}
func newBodyLeaf(bodyKind string) *Part {
return &Part{
MediaType: strings.ToLower(bodyKind),
MediaParams: map[string]string{"charset": "UTF-8"},
TransferEncoding: "7bit",
Headers: []Header{
{Name: "Content-Type", Value: mime.FormatMediaType(strings.ToLower(bodyKind), map[string]string{"charset": "UTF-8"})},
{Name: "Content-Transfer-Encoding", Value: "7bit"},
},
Body: []byte{},
}
}
func newMultipartContainer(mediaType string) *Part {
boundary := newBoundary()
return &Part{
MediaType: strings.ToLower(mediaType),
MediaParams: map[string]string{"boundary": boundary},
Headers: []Header{
{Name: "Content-Type", Value: mime.FormatMediaType(strings.ToLower(mediaType), map[string]string{"boundary": boundary})},
},
}
}
func addAttachment(dctx *DraftCtx, snapshot *DraftSnapshot, path string) error {
if err := checkBlockedExtension(filepath.Base(path)); err != nil {
return err
}
info, err := dctx.FIO.Stat(path)
if err != nil {
return err
}
if err := checkSnapshotAttachmentLimit(snapshot, info.Size(), nil); err != nil {
return err
}
f, err := dctx.FIO.Open(path)
if err != nil {
return err
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
return err
}
filename := filepath.Base(path)
contentType := "application/octet-stream"
mediaParams := map[string]string{}
mediaParams["name"] = filename
attachment := &Part{
MediaType: contentType,
MediaParams: mediaParams,
ContentDisposition: "attachment",
ContentDispositionArg: map[string]string{"filename": filename},
TransferEncoding: "base64",
Body: content,
Headers: []Header{
{Name: "Content-Type", Value: mime.FormatMediaType(contentType, cloneStringMap(mediaParams))},
{Name: "Content-Disposition", Value: mime.FormatMediaType("attachment", map[string]string{"filename": filename})},
{Name: "Content-Transfer-Encoding", Value: "base64"},
},
}
if snapshot.Body == nil {
snapshot.Body = attachment
snapshot.Body.Dirty = true
return nil
}
if strings.EqualFold(snapshot.Body.MediaType, "multipart/mixed") {
snapshot.Body.Children = append(snapshot.Body.Children, attachment)
snapshot.Body.Dirty = true
return nil
}
boundary := newBoundary()
original := snapshot.Body
snapshot.Body = &Part{
MediaType: "multipart/mixed",
MediaParams: map[string]string{"boundary": boundary},
Dirty: true,
Headers: []Header{
{Name: "Content-Type", Value: mime.FormatMediaType("multipart/mixed", map[string]string{"boundary": boundary})},
},
Children: []*Part{original, attachment},
}
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(dctx *DraftCtx, snapshot *DraftSnapshot, path, cid, fileName string, container *Part) (*Part, error) {
info, err := dctx.FIO.Stat(path)
if err != nil {
return nil, fmt.Errorf("inline image %q: %w", path, err)
}
if err := checkSnapshotAttachmentLimit(snapshot, info.Size(), nil); err != nil {
return nil, err
}
f, err := dctx.FIO.Open(path)
if err != nil {
return nil, fmt.Errorf("inline image %q: %w", path, err)
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("inline image %q: %w", path, err)
}
name := fileName
if strings.TrimSpace(name) == "" {
name = filepath.Base(path)
}
detectedCT, err := filecheck.CheckInlineImageFormat(name, content)
if err != nil {
return nil, fmt.Errorf("inline image %q: %w", path, err)
}
inline, err := newInlinePart(path, content, cid, name, detectedCT)
if err != nil {
return nil, fmt.Errorf("inline image %q: %w", path, err)
}
if container == nil {
containerRef := primaryBodyRootRef(&snapshot.Body)
if containerRef == nil || *containerRef == nil {
return nil, fmt.Errorf("draft has no primary body container")
}
container, err = ensureInlineContainerRef(containerRef)
if err != nil {
return nil, fmt.Errorf("inline image %q: %w", path, err)
}
}
container.Children = append(container.Children, inline)
container.Dirty = true
return container, nil
}
func addInline(dctx *DraftCtx, snapshot *DraftSnapshot, path, cid, fileName, contentType string) error {
_, err := loadAndAttachInline(dctx, snapshot, path, cid, fileName, nil)
return err
}
func replaceInline(dctx *DraftCtx, snapshot *DraftSnapshot, partID, path, cid, fileName, contentType string) error {
part := findPart(snapshot.Body, partID)
if part == nil {
return fmt.Errorf("inline part %q not found", partID)
}
if !isInlinePart(part) {
return fmt.Errorf("part %q is not an inline MIME part", partID)
}
info, err := dctx.FIO.Stat(path)
if err != nil {
return err
}
if err := checkSnapshotAttachmentLimit(snapshot, info.Size(), part); err != nil {
return err
}
f, err := dctx.FIO.Open(path)
if err != nil {
return err
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
return err
}
if strings.TrimSpace(fileName) == "" {
fileName = part.FileName()
}
if strings.TrimSpace(contentType) == "" {
contentType = part.MediaType
}
if strings.TrimSpace(cid) == "" {
cid = part.ContentID
}
if strings.TrimSpace(fileName) == "" {
fileName = filepath.Base(path)
}
detectedCT, err := filecheck.CheckInlineImageFormat(fileName, content)
if err != nil {
return err
}
contentType = detectedCT
contentType, mediaParams := normalizedDetectedMediaType(contentType)
finalCID := normalizeCID(cid)
if err := validateCID(finalCID); err != nil {
return err
}
if err := validate.RejectCRLF(fileName, "inline filename"); err != nil {
return err
}
mediaParams["name"] = fileName
part.MediaType = contentType
part.MediaParams = mediaParams
part.ContentDisposition = "inline"
part.ContentDispositionArg = map[string]string{"filename": fileName}
part.ContentID = finalCID
part.TransferEncoding = "base64"
part.Body = content
part.Dirty = true
syncStructuredPartHeaders(part)
return nil
}
func removeInline(snapshot *DraftSnapshot, partID string) error {
part := findPart(snapshot.Body, partID)
if part == nil {
return fmt.Errorf("inline part %q not found", partID)
}
if !isInlinePart(part) {
return fmt.Errorf("part %q is not an inline MIME part", partID)
}
if snapshot.Body == nil || snapshot.Body.PartID == partID {
return fmt.Errorf("cannot remove root MIME part")
}
if !removePart(snapshot.Body, partID) {
return fmt.Errorf("inline part %q not found", partID)
}
return nil
}
func removeAttachment(snapshot *DraftSnapshot, partID string) error {
if snapshot.Body == nil {
return fmt.Errorf("draft has no MIME body")
}
part := findPart(snapshot.Body, partID)
if part == nil {
return fmt.Errorf("attachment part %q not found", partID)
}
if strings.EqualFold(part.ContentDisposition, "inline") || part.ContentID != "" {
return fmt.Errorf("part %q is an inline MIME part; use remove_inline", partID)
}
if snapshot.Body.PartID == partID {
return fmt.Errorf("cannot remove root MIME part")
}
removed := removePart(snapshot.Body, partID)
if !removed {
return fmt.Errorf("attachment part %q not found", partID)
}
return nil
}
func removePart(parent *Part, targetPartID string) bool {
for idx, child := range parent.Children {
if child == nil {
continue
}
if child.PartID == targetPartID {
parent.Children = append(parent.Children[:idx], parent.Children[idx+1:]...)
parent.Dirty = true
return true
}
if removePart(child, targetPartID) {
parent.Dirty = true
return true
}
}
return false
}
// resolveTarget resolves an AttachmentTarget to a concrete part_id.
// Priority: part_id > cid.
func resolveTarget(snapshot *DraftSnapshot, target AttachmentTarget) (string, error) {
if id := strings.TrimSpace(target.PartID); id != "" {
return id, nil
}
if cid := strings.TrimSpace(target.CID); cid != "" {
cid = strings.Trim(cid, "<>")
part := findPartByCID(snapshot.Body, cid)
if part == nil {
return "", fmt.Errorf("no part with cid %q found", cid)
}
return part.PartID, nil
}
return "", fmt.Errorf("target must specify at least one of part_id or cid")
}
func findPartByCID(root *Part, cid string) *Part {
if root == nil {
return nil
}
if strings.EqualFold(strings.Trim(root.ContentID, "<>"), cid) {
return root
}
for _, child := range root.Children {
if found := findPartByCID(child, cid); found != nil {
return found
}
}
return nil
}
func findPart(root *Part, partID string) *Part {
if root == nil {
return nil
}
if root.PartID == partID {
return root
}
for _, child := range root.Children {
if child == nil {
continue
}
if found := findPart(child, partID); found != nil {
return found
}
}
return nil
}
// normalizeCID strips a single RFC 2392 angle-bracket wrapper (<...>) from the
// CID if present, and trims surrounding whitespace. Unlike strings.Trim, it
// only removes a matched pair so that stray brackets like "test<>" are preserved
// for validation to reject.
func normalizeCID(cid string) string {
cid = strings.TrimSpace(cid)
if strings.HasPrefix(cid, "<") && strings.HasSuffix(cid, ">") {
cid = cid[1 : len(cid)-1]
}
return cid
}
// validateCID checks that a Content-ID value is non-empty and free of
// characters that would break MIME headers or cause ambiguous references.
func validateCID(cid string) error {
if cid == "" {
return fmt.Errorf("inline cid is empty")
}
if err := validate.RejectCRLF(cid, "inline cid"); err != nil {
return err
}
if strings.ContainsAny(cid, " \t<>()") {
return fmt.Errorf("inline cid %q contains invalid characters (spaces, tabs, angle brackets, or parentheses are not allowed)", cid)
}
return nil
}
func ensureInlineContainerRef(partRef **Part) (*Part, error) {
if partRef == nil || *partRef == nil {
return nil, fmt.Errorf("body container is nil")
}
part := *partRef
if strings.EqualFold(part.MediaType, "multipart/related") {
return part, nil
}
related := newMultipartContainer("multipart/related")
related.Children = []*Part{part}
related.Dirty = true
*partRef = related
return related, nil
}
func newInlinePart(path string, content []byte, cid, fileName, contentType string) (*Part, error) {
if strings.TrimSpace(fileName) == "" {
fileName = filepath.Base(path)
}
if strings.TrimSpace(contentType) == "" {
contentType = mime.TypeByExtension(filepath.Ext(fileName))
}
contentType, mediaParams := normalizedDetectedMediaType(contentType)
mediaParams["name"] = fileName
cid = normalizeCID(cid)
if err := validateCID(cid); err != nil {
return nil, err
}
if err := validate.RejectCRLF(fileName, "inline filename"); err != nil {
return nil, err
}
part := &Part{
MediaType: contentType,
MediaParams: mediaParams,
ContentDisposition: "inline",
ContentDispositionArg: map[string]string{"filename": fileName},
ContentID: cid,
TransferEncoding: "base64",
Body: content,
Dirty: true,
}
syncStructuredPartHeaders(part)
return part, nil
}
func normalizedDetectedMediaType(detected string) (string, map[string]string) {
detected = strings.TrimSpace(detected)
if detected == "" {
return "application/octet-stream", map[string]string{}
}
mediaType, params, err := mime.ParseMediaType(detected)
if err != nil || strings.TrimSpace(mediaType) == "" {
return detected, map[string]string{}
}
normalized := lowerCaseKeys(params)
if normalized == nil {
normalized = map[string]string{}
}
return mediaType, normalized
}
func syncStructuredPartHeaders(part *Part) {
if part == nil {
return
}
headers := make([]Header, 0, len(part.Headers)+4)
for _, header := range part.Headers {
switch strings.ToLower(header.Name) {
case "content-type", "content-transfer-encoding", "content-disposition", "content-id":
continue
default:
headers = append(headers, header)
}
}
headers = append(headers, Header{Name: "Content-Type", Value: mime.FormatMediaType(part.MediaType, cloneStringMap(part.MediaParams))})
if part.ContentDisposition != "" {
headers = append(headers, Header{Name: "Content-Disposition", Value: mime.FormatMediaType(part.ContentDisposition, cloneStringMap(part.ContentDispositionArg))})
}
if part.ContentID != "" {
headers = append(headers, Header{Name: "Content-ID", Value: "<" + part.ContentID + ">"})
}
if part.TransferEncoding != "" {
headers = append(headers, Header{Name: "Content-Transfer-Encoding", Value: part.TransferEncoding})
}
part.Headers = headers
}
func isInlinePart(part *Part) bool {
if part == nil {
return false
}
return strings.EqualFold(part.ContentDisposition, "inline") || strings.TrimSpace(part.ContentID) != ""
}
func upsertHeader(headers *[]Header, name, value string) {
for i, header := range *headers {
if strings.EqualFold(header.Name, name) {
(*headers)[i].Value = value
j := i + 1
for j < len(*headers) {
if strings.EqualFold((*headers)[j].Name, name) {
*headers = append((*headers)[:j], (*headers)[j+1:]...)
continue
}
j++
}
return
}
}
*headers = append(*headers, Header{Name: name, Value: value})
}
func removeHeader(headers *[]Header, name string) {
next := (*headers)[:0]
for _, header := range *headers {
if strings.EqualFold(header.Name, name) {
continue
}
next = append(next, header)
}
*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
}
// LocalImageRef represents a local image found in an HTML body that needs
// to be embedded as an inline MIME part.
type LocalImageRef struct {
FilePath string // original src value from the HTML
CID string // generated Content-ID
}
// ResolveLocalImagePaths scans HTML for <img src="local/path"> references,
// validates each path, generates CIDs, and returns the modified HTML with
// cid: URIs plus the list of local image references to embed as inline parts.
// This function handles only the HTML transformation; callers are responsible
// for embedding the actual file data (e.g., via emlbuilder.AddFileInline).
func ResolveLocalImagePaths(html string) (string, []LocalImageRef, error) {
matches := imgSrcRegexp.FindAllStringSubmatchIndex(html, -1)
if len(matches) == 0 {
return html, nil, nil
}
// Cache resolved paths so the same file is only attached once.
pathToCID := make(map[string]string)
var refs []LocalImageRef
// Iterate in reverse so that index offsets remain valid after replacement.
for i := len(matches) - 1; i >= 0; i-- {
srcStart, srcEnd := matches[i][2], matches[i][3]
src := html[srcStart:srcEnd]
if !isLocalFileSrc(src) {
continue
}
resolvedPath, err := validate.SafeInputPath(src)
if err != nil {
return "", nil, fmt.Errorf("inline image %q: %w", src, err)
}
cid, ok := pathToCID[resolvedPath]
if !ok {
cid, err = generateCID()
if err != nil {
return "", nil, err
}
pathToCID[resolvedPath] = cid
refs = append(refs, LocalImageRef{FilePath: src, CID: cid})
}
html = html[:srcStart] + "cid:" + cid + html[srcEnd:]
}
return html, refs, nil
}
// resolveLocalImgSrc scans HTML for <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(dctx *DraftCtx, snapshot *DraftSnapshot, html string) (string, error) {
resolved, refs, err := ResolveLocalImagePaths(html)
if err != nil {
return "", err
}
var container *Part
for _, ref := range refs {
fileName := filepath.Base(ref.FilePath)
container, err = loadAndAttachInline(dctx, snapshot, ref.FilePath, ref.CID, fileName, container)
if err != nil {
return "", err
}
}
return resolved, nil
}
// removeOrphanedInlineParts removes inline MIME parts whose ContentID
// is not in the referencedCIDs set. It searches multipart/related and
// multipart/mixed containers, because some servers flatten the MIME tree
// and place inline parts directly under multipart/mixed.
func removeOrphanedInlineParts(root *Part, referencedCIDs map[string]bool) {
if root == nil {
return
}
isRelated := strings.EqualFold(root.MediaType, "multipart/related")
isMixed := strings.EqualFold(root.MediaType, "multipart/mixed")
if !isRelated && !isMixed {
for _, child := range root.Children {
removeOrphanedInlineParts(child, referencedCIDs)
}
return
}
kept := make([]*Part, 0, len(root.Children))
for _, child := range root.Children {
if child == nil {
continue
}
if strings.EqualFold(child.ContentDisposition, "inline") && child.ContentID != "" {
if !referencedCIDs[strings.ToLower(child.ContentID)] {
root.Dirty = true
continue
}
}
kept = append(kept, child)
}
root.Children = kept
for _, child := range root.Children {
removeOrphanedInlineParts(child, referencedCIDs)
}
}
// ValidateCIDReferences checks that every cid: reference in the HTML body has
// a matching entry in availableCIDs. Returns an error for the first missing CID.
// Both sides are compared case-insensitively.
func ValidateCIDReferences(html string, availableCIDs []string) error {
refs := extractCIDRefs(html)
if len(refs) == 0 {
return nil
}
cidSet := make(map[string]bool, len(availableCIDs))
for _, cid := range availableCIDs {
cidSet[strings.ToLower(cid)] = true
}
for _, ref := range refs {
if !cidSet[strings.ToLower(ref)] {
return fmt.Errorf("html body references missing inline cid %q", ref)
}
}
return nil
}
// FindOrphanedCIDs returns CIDs from addedCIDs that are not referenced in the
// HTML body via <img src="cid:...">. These would appear as unexpected
// attachments when the email is sent.
func FindOrphanedCIDs(html string, addedCIDs []string) []string {
refs := extractCIDRefs(html)
refSet := make(map[string]bool, len(refs))
for _, ref := range refs {
refSet[strings.ToLower(ref)] = true
}
var orphaned []string
for _, cid := range addedCIDs {
if !refSet[strings.ToLower(cid)] {
orphaned = append(orphaned, cid)
}
}
return orphaned
}
// postProcessInlineImages is the unified post-processing step that:
// 1. Resolves local <img src="./path"> to inline CID parts (only when resolveLocal is true).
// 2. Validates all CID references in HTML resolve to MIME parts.
// 3. Removes orphaned inline MIME parts no longer referenced by HTML.
//
// resolveLocal should be true only when a body-changing op was applied;
// metadata-only edits skip local path resolution to avoid disk I/O side effects.
//
// NOTE: The EML builder path has an equivalent function processInlineImagesForEML
// in shortcuts/mail/helpers.go. When adding new validation or processing logic here,
// update processInlineImagesForEML as well (or extract a shared function).
func postProcessInlineImages(dctx *DraftCtx, snapshot *DraftSnapshot, resolveLocal bool) error {
htmlPart := findPrimaryBodyPart(snapshot.Body, "text/html")
if htmlPart == nil {
return nil
}
origHTML := string(htmlPart.Body)
html := origHTML
if resolveLocal {
var err error
html, err = resolveLocalImgSrc(dctx, snapshot, origHTML)
if err != nil {
return err
}
if html != origHTML {
htmlPart.Body = []byte(html)
htmlPart.Dirty = true
}
}
// Collect all CIDs present as MIME parts.
var cidParts []string
for _, part := range flattenParts(snapshot.Body) {
if part != nil && part.ContentID != "" {
cidParts = append(cidParts, part.ContentID)
}
}
if err := ValidateCIDReferences(html, cidParts); err != nil {
return err
}
refs := extractCIDRefs(html)
refSet := make(map[string]bool, len(refs))
for _, ref := range refs {
refSet[strings.ToLower(ref)] = true
}
removeOrphanedInlineParts(snapshot.Body, refSet)
return nil
}
// ── Signature patch operations ──
// insertSignatureOp inserts a pre-rendered signature into the HTML body.
// The RenderedSignatureHTML and SignatureImages fields must be populated
// by the shortcut layer before calling Apply.
//
// Placement: signature goes between the user-authored region and any
// system-managed tail (large attachment card or history quote wrapper),
// matching the compose-time order [user][sig][card?][quote?]. When the
// draft already has a signature, it is replaced in place.
func insertSignatureOp(snapshot *DraftSnapshot, op PatchOp) error {
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if htmlPart == nil {
return fmt.Errorf("insert_signature: no HTML body part found; use set_body first")
}
oldHTML := string(htmlPart.Body)
// Collect CIDs from old signature before replacement so we can prune
// MIME inline parts that the new signature doesn't re-reference.
oldSigCIDs := collectSignatureCIDsFromHTML(oldHTML)
sigBlock := SignatureSpacing() + BuildSignatureHTML(op.SignatureID, op.RenderedSignatureHTML)
newHTML := PlaceSignatureBeforeSystemTail(oldHTML, sigBlock)
// Remove orphaned MIME inline parts from old signature.
for _, cid := range oldSigCIDs {
if !containsCIDIgnoreCase(newHTML, cid) {
removeMIMEPartByCID(snapshot.Body, cid)
}
}
htmlPart.Body = []byte(newHTML)
htmlPart.Dirty = true
// Add new signature inline images to the MIME tree.
for _, img := range op.SignatureImages {
addInlinePartToSnapshot(snapshot, img.Data, img.ContentType, img.FileName, img.CID)
}
syncTextPartFromHTML(snapshot, newHTML)
return nil
}
// removeSignatureOp removes the signature block from the HTML body.
func removeSignatureOp(snapshot *DraftSnapshot) error {
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if htmlPart == nil {
return fmt.Errorf("remove_signature: no HTML body part found")
}
html := string(htmlPart.Body)
if !signatureWrapperRe.MatchString(html) {
return fmt.Errorf("no signature found in draft body")
}
// Collect CIDs referenced by the signature before removing it.
sigCIDs := collectSignatureCIDsFromHTML(html)
// Remove signature and preceding spacing.
html = RemoveSignatureHTML(html)
// Remove orphaned inline parts (only if the CID is no longer referenced in remaining HTML).
for _, cid := range sigCIDs {
if !containsCIDIgnoreCase(html, cid) {
removeMIMEPartByCID(snapshot.Body, cid)
}
}
htmlPart.Body = []byte(html)
htmlPart.Dirty = true
syncTextPartFromHTML(snapshot, html)
return nil
}
// syncTextPartFromHTML regenerates the text/plain part from the current HTML,
// mirroring the coupled-body logic in tryApplyCoupledBodySetBody.
func syncTextPartFromHTML(snapshot *DraftSnapshot, html string) {
if snapshot.PrimaryTextPartID == "" {
return
}
textPart := findPart(snapshot.Body, snapshot.PrimaryTextPartID)
if textPart == nil {
return
}
textPart.Body = []byte(plainTextFromHTML(html))
textPart.Dirty = true
}
// Note: SignatureSpacing, BuildSignatureHTML, FindMatchingCloseDiv, and
// RemoveSignatureHTML are exported from projection.go to avoid duplication
// with the mail package's signature_html.go.
// collectSignatureCIDsFromHTML extracts CID references from the signature block in HTML.
func collectSignatureCIDsFromHTML(html string) []string {
loc := signatureWrapperRe.FindStringIndex(html)
if loc == nil {
return nil
}
sigEnd := FindMatchingCloseDiv(html, loc[0])
sigHTML := html[loc[0]:sigEnd]
matches := cidRefRegexp.FindAllStringSubmatch(sigHTML, -1)
cids := make([]string, 0, len(matches))
for _, m := range matches {
if len(m) >= 2 {
cids = append(cids, m[1])
}
}
return cids
}
// removeMIMEPartByCID removes the first MIME part with the given Content-ID.
func removeMIMEPartByCID(root *Part, cid string) {
if root == nil {
return
}
normalizedCID := strings.Trim(cid, "<>")
for i, child := range root.Children {
if child == nil {
continue
}
childCID := strings.Trim(child.ContentID, "<>")
if strings.EqualFold(childCID, normalizedCID) {
root.Children = append(root.Children[:i], root.Children[i+1:]...)
return
}
removeMIMEPartByCID(child, cid)
}
}
// addInlinePartToSnapshot adds an inline image part to the MIME tree.
func addInlinePartToSnapshot(snapshot *DraftSnapshot, data []byte, contentType, filename, cid string) {
part := &Part{
MediaType: contentType,
ContentDisposition: "inline",
ContentID: strings.Trim(cid, "<>"),
Body: data,
Dirty: true,
}
if filename != "" {
part.MediaParams = map[string]string{"name": filename}
}
// Find or create the multipart/related container.
if snapshot.Body == nil {
return
}
if snapshot.Body.IsMultipart() {
snapshot.Body.Children = append(snapshot.Body.Children, part)
}
// Non-multipart body: inline part is not added. This is expected when
// the draft has a simple text/html body without multipart/related wrapper.
// The signature HTML still references the CID, but the image won't render.
// In practice, compose shortcuts wrap the body in multipart/related when
// inline images are present, so this path rarely triggers.
}
// containsCIDIgnoreCase checks if html contains a "cid:<value>" reference,
// case-insensitively. Aligned with other CID comparisons in this package.
func containsCIDIgnoreCase(html, cid string) bool {
return strings.Contains(strings.ToLower(html), "cid:"+strings.ToLower(cid))
}