@@ -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,141 @@ 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+ // Remove existing signature (if any), including preceding spacing.
1158+ html = RemoveSignatureHTML (html )
1159+
1160+ // Split at quote and insert signature between body and quote.
1161+ body , quote := SplitAtQuote (html )
1162+ sigBlock := SignatureSpacing () + BuildSignatureHTML (op .SignatureID , op .RenderedSignatureHTML )
1163+ html = body + sigBlock + quote
1164+
1165+ htmlPart .Body = []byte (html )
1166+ htmlPart .Dirty = true
1167+
1168+ // Add signature inline images to the MIME tree.
1169+ for _ , img := range op .SignatureImages {
1170+ addInlinePartToSnapshot (snapshot , img .Data , img .ContentType , img .FileName , img .CID )
1171+ }
1172+
1173+ syncTextPartFromHTML (snapshot , html )
1174+ return nil
1175+ }
1176+
1177+ // removeSignatureOp removes the signature block from the HTML body.
1178+ func removeSignatureOp (snapshot * DraftSnapshot ) error {
1179+ htmlPart := findPart (snapshot .Body , snapshot .PrimaryHTMLPartID )
1180+ if htmlPart == nil {
1181+ return fmt .Errorf ("remove_signature: no HTML body part found" )
1182+ }
1183+ html := string (htmlPart .Body )
1184+
1185+ if ! signatureWrapperRe .MatchString (html ) {
1186+ return fmt .Errorf ("no signature found in draft body" )
1187+ }
1188+
1189+ // Collect CIDs referenced by the signature before removing it.
1190+ sigCIDs := collectSignatureCIDsFromHTML (html )
1191+
1192+ // Remove signature and preceding spacing.
1193+ html = RemoveSignatureHTML (html )
1194+
1195+ // Remove orphaned inline parts (only if the CID is no longer referenced in remaining HTML).
1196+ for _ , cid := range sigCIDs {
1197+ if ! strings .Contains (html , "cid:" + cid ) {
1198+ removeMIMEPartByCID (snapshot .Body , cid )
1199+ }
1200+ }
1201+
1202+ htmlPart .Body = []byte (html )
1203+ htmlPart .Dirty = true
1204+
1205+ syncTextPartFromHTML (snapshot , html )
1206+ return nil
1207+ }
1208+
1209+ // syncTextPartFromHTML regenerates the text/plain part from the current HTML,
1210+ // mirroring the coupled-body logic in tryApplyCoupledBodySetBody.
1211+ func syncTextPartFromHTML (snapshot * DraftSnapshot , html string ) {
1212+ if snapshot .PrimaryTextPartID == "" {
1213+ return
1214+ }
1215+ textPart := findPart (snapshot .Body , snapshot .PrimaryTextPartID )
1216+ if textPart == nil {
1217+ return
1218+ }
1219+ textPart .Body = []byte (plainTextFromHTML (html ))
1220+ textPart .Dirty = true
1221+ }
1222+
1223+ // Note: SignatureSpacing, BuildSignatureHTML, FindMatchingCloseDiv, and
1224+ // RemoveSignatureHTML are exported from projection.go to avoid duplication
1225+ // with the mail package's signature_html.go.
1226+
1227+ // collectSignatureCIDsFromHTML extracts CID references from the signature block in HTML.
1228+ func collectSignatureCIDsFromHTML (html string ) []string {
1229+ loc := signatureWrapperRe .FindStringIndex (html )
1230+ if loc == nil {
1231+ return nil
1232+ }
1233+ sigEnd := FindMatchingCloseDiv (html , loc [0 ])
1234+ sigHTML := html [loc [0 ]:sigEnd ]
1235+
1236+ matches := cidRefRegexp .FindAllStringSubmatch (sigHTML , - 1 )
1237+ cids := make ([]string , 0 , len (matches ))
1238+ for _ , m := range matches {
1239+ if len (m ) >= 2 {
1240+ cids = append (cids , m [1 ])
1241+ }
1242+ }
1243+ return cids
1244+ }
1245+
1246+ // removeMIMEPartByCID removes the first MIME part with the given Content-ID.
1247+ func removeMIMEPartByCID (root * Part , cid string ) {
1248+ if root == nil {
1249+ return
1250+ }
1251+ normalizedCID := strings .Trim (cid , "<>" )
1252+ for i , child := range root .Children {
1253+ childCID := strings .Trim (child .ContentID , "<>" )
1254+ if strings .EqualFold (childCID , normalizedCID ) {
1255+ root .Children = append (root .Children [:i ], root .Children [i + 1 :]... )
1256+ return
1257+ }
1258+ removeMIMEPartByCID (child , cid )
1259+ }
1260+ }
1261+
1262+ // addInlinePartToSnapshot adds an inline image part to the MIME tree.
1263+ func addInlinePartToSnapshot (snapshot * DraftSnapshot , data []byte , contentType , filename , cid string ) {
1264+ part := & Part {
1265+ MediaType : contentType ,
1266+ ContentDisposition : "inline" ,
1267+ ContentID : strings .Trim (cid , "<>" ),
1268+ Body : data ,
1269+ Dirty : true ,
1270+ }
1271+ if filename != "" {
1272+ part .MediaParams = map [string ]string {"name" : filename }
1273+ }
1274+ // Find or create the multipart/related container.
1275+ if snapshot .Body == nil {
1276+ return
1277+ }
1278+ if snapshot .Body .IsMultipart () {
1279+ snapshot .Body .Children = append (snapshot .Body .Children , part )
1280+ }
1281+ }
0 commit comments