// 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 and captures the src value. // It handles both single and double quotes. var imgSrcRegexp = regexp.MustCompile(`(?i)]*?\s)?src\s*=\s*["']([^"']+)["']`) var protectedHeaders = map[string]bool{ "message-id": true, "mime-version": true, "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 references, // validates each path, generates CIDs, and returns the modified HTML with // cid: URIs plus the list of local image references to embed as inline parts. // This function handles only the HTML transformation; callers are responsible // for embedding the actual file data (e.g., via emlbuilder.AddFileInline). func ResolveLocalImagePaths(html string) (string, []LocalImageRef, error) { matches := imgSrcRegexp.FindAllStringSubmatchIndex(html, -1) if len(matches) == 0 { return html, nil, nil } // Cache resolved paths so the same file is only attached once. pathToCID := make(map[string]string) var refs []LocalImageRef // Iterate in reverse so that index offsets remain valid after replacement. for i := len(matches) - 1; i >= 0; i-- { srcStart, srcEnd := matches[i][2], matches[i][3] src := html[srcStart:srcEnd] if !isLocalFileSrc(src) { continue } resolvedPath, err := validate.SafeInputPath(src) if err != nil { return "", nil, fmt.Errorf("inline image %q: %w", src, err) } cid, ok := pathToCID[resolvedPath] if !ok { cid, err = generateCID() if err != nil { return "", nil, err } pathToCID[resolvedPath] = cid refs = append(refs, LocalImageRef{FilePath: src, CID: cid}) } html = html[:srcStart] + "cid:" + cid + html[srcEnd:] } return html, refs, nil } // resolveLocalImgSrc scans HTML for references, // creates MIME inline parts for each local file, and returns the HTML // with those src attributes replaced by cid: URIs. func resolveLocalImgSrc(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 . 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 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:" 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)) }