Skip to content

Commit fbb3ce9

Browse files
committed
feat(mail): add email signature support
Change-Id: Id5c1019cf407af9b3a3e1329f424ab5d32990ec6
1 parent 44e7b5b commit fbb3ce9

28 files changed

Lines changed: 1728 additions & 38 deletions

shortcuts/mail/draft/model.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ type DraftProjection struct {
144144
BodyText string `json:"body_text,omitempty"`
145145
BodyHTMLSummary string `json:"body_html_summary,omitempty"`
146146
HasQuotedContent bool `json:"has_quoted_content,omitempty"`
147+
HasSignature bool `json:"has_signature,omitempty"`
148+
SignatureID string `json:"signature_id,omitempty"`
147149
AttachmentsSummary []PartSummary `json:"attachments_summary,omitempty"`
148150
InlineSummary []PartSummary `json:"inline_summary,omitempty"`
149151
Warnings []string `json:"warnings,omitempty"`
@@ -182,6 +184,22 @@ type PatchOp struct {
182184
FileName string `json:"filename,omitempty"`
183185
ContentType string `json:"content_type,omitempty"`
184186
Target AttachmentTarget `json:"target,omitempty"`
187+
SignatureID string `json:"signature_id,omitempty"`
188+
189+
// RenderedSignatureHTML is set by the shortcut layer (not from JSON) after
190+
// fetching and interpolating the signature. The patch layer uses this
191+
// pre-rendered content for insert_signature ops.
192+
RenderedSignatureHTML string `json:"-"`
193+
SignatureImages []SignatureImage `json:"-"`
194+
}
195+
196+
// SignatureImage holds pre-downloaded image data for signature inline images.
197+
// Populated by the shortcut layer, consumed by the patch layer.
198+
type SignatureImage struct {
199+
CID string
200+
ContentType string
201+
FileName string
202+
Data []byte
185203
}
186204

187205
func (p Patch) Validate() error {
@@ -274,6 +292,12 @@ func (op PatchOp) Validate() error {
274292
if !op.Target.hasKey() {
275293
return fmt.Errorf("remove_inline requires target with at least one of part_id or cid")
276294
}
295+
case "insert_signature":
296+
if strings.TrimSpace(op.SignatureID) == "" {
297+
return fmt.Errorf("insert_signature requires signature_id")
298+
}
299+
case "remove_signature":
300+
// No required fields.
277301
default:
278302
return fmt.Errorf("unsupported op %q", op.Op)
279303
}

shortcuts/mail/draft/patch.go

Lines changed: 174 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
3535
var 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

4244
func 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

Comments
 (0)