@@ -33,10 +33,12 @@ var protectedHeaders = map[string]bool{
3333// bodyChangingOps lists patch operations that modify the HTML body content,
3434// which is the trigger for running local image path resolution.
3535var bodyChangingOps = map [string ]bool {
36- "set_body" : true ,
37- "set_reply_body" : true ,
38- "replace_body" : true ,
39- "append_body" : true ,
36+ "set_body" : true ,
37+ "set_reply_body" : true ,
38+ "replace_body" : true ,
39+ "append_body" : true ,
40+ "insert_signature" : true ,
41+ "remove_signature" : true ,
4042}
4143
4244func Apply (dctx * DraftCtx , snapshot * DraftSnapshot , patch Patch ) error {
@@ -121,6 +123,10 @@ func applyOp(dctx *DraftCtx, snapshot *DraftSnapshot, op PatchOp, options PatchO
121123 return fmt .Errorf ("remove_inline: %w" , err )
122124 }
123125 return removeInline (snapshot , partID )
126+ case "insert_signature" :
127+ return insertSignatureOp (snapshot , op )
128+ case "remove_signature" :
129+ return removeSignatureOp (snapshot )
124130 default :
125131 return fmt .Errorf ("unsupported patch op %q" , op .Op )
126132 }
@@ -284,7 +290,7 @@ func setReplyBody(snapshot *DraftSnapshot, value string, options PatchOptions) e
284290 if htmlPart == nil {
285291 return setBody (snapshot , value , options )
286292 }
287- _ , quotePart := splitAtQuote (string (htmlPart .Body ))
293+ _ , quotePart := SplitAtQuote (string (htmlPart .Body ))
288294 if quotePart == "" {
289295 // No quote block found — fall back to regular set_body.
290296 return setBody (snapshot , value , options )
@@ -1135,3 +1141,166 @@ func postProcessInlineImages(dctx *DraftCtx, snapshot *DraftSnapshot, resolveLoc
11351141 removeOrphanedInlineParts (snapshot .Body , refSet )
11361142 return nil
11371143}
1144+
1145+ // ── Signature patch operations ──
1146+
1147+ // insertSignatureOp inserts a pre-rendered signature into the HTML body.
1148+ // The RenderedSignatureHTML and SignatureImages fields must be populated
1149+ // by the shortcut layer before calling Apply.
1150+ func insertSignatureOp (snapshot * DraftSnapshot , op PatchOp ) error {
1151+ htmlPart := findPart (snapshot .Body , snapshot .PrimaryHTMLPartID )
1152+ if htmlPart == nil {
1153+ return fmt .Errorf ("insert_signature: no HTML body part found; use set_body first" )
1154+ }
1155+ html := string (htmlPart .Body )
1156+
1157+ // Collect CIDs from old signature before removing it, so we can
1158+ // clean up orphaned MIME inline parts and avoid duplicates.
1159+ oldSigCIDs := collectSignatureCIDsFromHTML (html )
1160+
1161+ // Remove existing signature (if any), including preceding spacing.
1162+ html = RemoveSignatureHTML (html )
1163+
1164+ // Remove orphaned MIME inline parts from old signature.
1165+ for _ , cid := range oldSigCIDs {
1166+ if ! containsCIDIgnoreCase (html , cid ) {
1167+ removeMIMEPartByCID (snapshot .Body , cid )
1168+ }
1169+ }
1170+
1171+ // Split at quote and insert signature between body and quote.
1172+ body , quote := SplitAtQuote (html )
1173+ sigBlock := SignatureSpacing () + BuildSignatureHTML (op .SignatureID , op .RenderedSignatureHTML )
1174+ html = body + sigBlock + quote
1175+
1176+ htmlPart .Body = []byte (html )
1177+ htmlPart .Dirty = true
1178+
1179+ // Add signature inline images to the MIME tree.
1180+ for _ , img := range op .SignatureImages {
1181+ addInlinePartToSnapshot (snapshot , img .Data , img .ContentType , img .FileName , img .CID )
1182+ }
1183+
1184+ syncTextPartFromHTML (snapshot , html )
1185+ return nil
1186+ }
1187+
1188+ // removeSignatureOp removes the signature block from the HTML body.
1189+ func removeSignatureOp (snapshot * DraftSnapshot ) error {
1190+ htmlPart := findPart (snapshot .Body , snapshot .PrimaryHTMLPartID )
1191+ if htmlPart == nil {
1192+ return fmt .Errorf ("remove_signature: no HTML body part found" )
1193+ }
1194+ html := string (htmlPart .Body )
1195+
1196+ if ! signatureWrapperRe .MatchString (html ) {
1197+ return fmt .Errorf ("no signature found in draft body" )
1198+ }
1199+
1200+ // Collect CIDs referenced by the signature before removing it.
1201+ sigCIDs := collectSignatureCIDsFromHTML (html )
1202+
1203+ // Remove signature and preceding spacing.
1204+ html = RemoveSignatureHTML (html )
1205+
1206+ // Remove orphaned inline parts (only if the CID is no longer referenced in remaining HTML).
1207+ for _ , cid := range sigCIDs {
1208+ if ! containsCIDIgnoreCase (html , cid ) {
1209+ removeMIMEPartByCID (snapshot .Body , cid )
1210+ }
1211+ }
1212+
1213+ htmlPart .Body = []byte (html )
1214+ htmlPart .Dirty = true
1215+
1216+ syncTextPartFromHTML (snapshot , html )
1217+ return nil
1218+ }
1219+
1220+ // syncTextPartFromHTML regenerates the text/plain part from the current HTML,
1221+ // mirroring the coupled-body logic in tryApplyCoupledBodySetBody.
1222+ func syncTextPartFromHTML (snapshot * DraftSnapshot , html string ) {
1223+ if snapshot .PrimaryTextPartID == "" {
1224+ return
1225+ }
1226+ textPart := findPart (snapshot .Body , snapshot .PrimaryTextPartID )
1227+ if textPart == nil {
1228+ return
1229+ }
1230+ textPart .Body = []byte (plainTextFromHTML (html ))
1231+ textPart .Dirty = true
1232+ }
1233+
1234+ // Note: SignatureSpacing, BuildSignatureHTML, FindMatchingCloseDiv, and
1235+ // RemoveSignatureHTML are exported from projection.go to avoid duplication
1236+ // with the mail package's signature_html.go.
1237+
1238+ // collectSignatureCIDsFromHTML extracts CID references from the signature block in HTML.
1239+ func collectSignatureCIDsFromHTML (html string ) []string {
1240+ loc := signatureWrapperRe .FindStringIndex (html )
1241+ if loc == nil {
1242+ return nil
1243+ }
1244+ sigEnd := FindMatchingCloseDiv (html , loc [0 ])
1245+ sigHTML := html [loc [0 ]:sigEnd ]
1246+
1247+ matches := cidRefRegexp .FindAllStringSubmatch (sigHTML , - 1 )
1248+ cids := make ([]string , 0 , len (matches ))
1249+ for _ , m := range matches {
1250+ if len (m ) >= 2 {
1251+ cids = append (cids , m [1 ])
1252+ }
1253+ }
1254+ return cids
1255+ }
1256+
1257+ // removeMIMEPartByCID removes the first MIME part with the given Content-ID.
1258+ func removeMIMEPartByCID (root * Part , cid string ) {
1259+ if root == nil {
1260+ return
1261+ }
1262+ normalizedCID := strings .Trim (cid , "<>" )
1263+ for i , child := range root .Children {
1264+ if child == nil {
1265+ continue
1266+ }
1267+ childCID := strings .Trim (child .ContentID , "<>" )
1268+ if strings .EqualFold (childCID , normalizedCID ) {
1269+ root .Children = append (root .Children [:i ], root .Children [i + 1 :]... )
1270+ return
1271+ }
1272+ removeMIMEPartByCID (child , cid )
1273+ }
1274+ }
1275+
1276+ // addInlinePartToSnapshot adds an inline image part to the MIME tree.
1277+ func addInlinePartToSnapshot (snapshot * DraftSnapshot , data []byte , contentType , filename , cid string ) {
1278+ part := & Part {
1279+ MediaType : contentType ,
1280+ ContentDisposition : "inline" ,
1281+ ContentID : strings .Trim (cid , "<>" ),
1282+ Body : data ,
1283+ Dirty : true ,
1284+ }
1285+ if filename != "" {
1286+ part .MediaParams = map [string ]string {"name" : filename }
1287+ }
1288+ // Find or create the multipart/related container.
1289+ if snapshot .Body == nil {
1290+ return
1291+ }
1292+ if snapshot .Body .IsMultipart () {
1293+ snapshot .Body .Children = append (snapshot .Body .Children , part )
1294+ }
1295+ // Non-multipart body: inline part is not added. This is expected when
1296+ // the draft has a simple text/html body without multipart/related wrapper.
1297+ // The signature HTML still references the CID, but the image won't render.
1298+ // In practice, compose shortcuts wrap the body in multipart/related when
1299+ // inline images are present, so this path rarely triggers.
1300+ }
1301+
1302+ // containsCIDIgnoreCase checks if html contains a "cid:<value>" reference,
1303+ // case-insensitively. Aligned with other CID comparisons in this package.
1304+ func containsCIDIgnoreCase (html , cid string ) bool {
1305+ return strings .Contains (strings .ToLower (html ), "cid:" + strings .ToLower (cid ))
1306+ }
0 commit comments