mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
* 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.
1376 lines
43 KiB
Go
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))
|
|
}
|