Files
evandance 5e6a3eb857 feat(mail): return typed error envelopes across the mail domain (#1250)
* feat(mail): return typed error envelopes across the mail domain

Replace every produced error path in shortcuts/mail with typed errs.* envelopes, so consumers get stable category, subtype, param/params, hint, retryable, and log_id metadata for classification and recovery instead of free-form message text.

- Locally constructed mail errors move from output.Err* / output.Errorf / final fmt.Errorf / common legacy helpers to errs.* builders, with structured params on multi-flag validation and failed-precondition states kept non-retryable.

- API-call failures move from runtime.CallAPI / DoAPIJSON legacy boundaries to runtime.CallAPITyped or runtime.ClassifyAPIResponse, and mail-specific enrichers read errs.ProblemOf so typed code, subtype, hint, and log_id metadata are preserved.

- Batch draft-send partial failures now use runtime.OutPartialFailure so successful and failed draft sends stay in stdout while the command exits through a typed multi-status signal.

- Add mail-domain typed helpers, mail API code metadata, and guard wiring to keep shortcuts/mail from reintroducing legacy envelopes or legacy API calls.

- Keep genuine intermediate fmt.Errorf wraps in parser/builder layers annotated with nolint comments; command-facing paths wrap them into typed validation, API, network, or internal errors.

* fix(mail): report aborted draft-send batches as a single failure result

When an account-level failure interrupts a batch send after some drafts
already went out, the command previously produced two machine-readable
failure results: the partial-failure ledger on stdout and a second error
envelope on stderr. Consumers could not tell which one to recover from.

The batch ledger is now the only failure result for that case: it gains
aborted and abort_error fields carrying the typed cause, so callers can
see which drafts were sent, which failed, why the batch stopped, and how
to recover — all from stdout. A --stop-on-error stop keeps these fields
unset because stopping early there is the caller's own choice.
2026-06-04 21:02:20 +08:00

1376 lines
43 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//nolint:forbidigo // intermediate draft patch application errors; mail command layer wraps into typed ValidationError.
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))
}