Skip to content

Commit 481da2f

Browse files
committed
feat(mail): add email signature support
Add +signature shortcut for listing/viewing signatures, --signature-id flag to all compose shortcuts (draft-create, send, reply, reply-all, forward), signature detection/insert/remove patch ops for draft-edit, and signature image download with inline CID embedding. Change-Id: Iecdded07d11a000892b3cfcd288f360dde375fa5
1 parent 162c255 commit 481da2f

27 files changed

Lines changed: 1469 additions & 34 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: 149 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,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+
}

shortcuts/mail/draft/projection.go

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ var quoteWrapperRe = regexp.MustCompile(`<div\s[^>]*class="[^"]*` + QuoteWrapper
2727

2828
var cidRefRegexp = regexp.MustCompile(`(?i)cid:([^"' >]+)`)
2929

30+
// SignatureWrapperClass is the CSS class for the mail signature container.
31+
const SignatureWrapperClass = "lark-mail-signature"
32+
33+
var signatureWrapperRe = regexp.MustCompile(
34+
`<div\s[^>]*class="[^"]*` + SignatureWrapperClass + `[^"]*"`)
35+
36+
var signatureIDRe = regexp.MustCompile(
37+
`<div\s[^>]*id="([^"]*)"[^>]*class="[^"]*` + SignatureWrapperClass)
38+
3039
func Project(snapshot *DraftSnapshot) DraftProjection {
3140
proj := DraftProjection{
3241
Subject: snapshot.Subject,
@@ -45,6 +54,12 @@ func Project(snapshot *DraftSnapshot) DraftProjection {
4554
html := string(part.Body)
4655
proj.BodyHTMLSummary = summarizeHTML(html)
4756
proj.HasQuotedContent = hasQuotedContent(html)
57+
proj.HasSignature = signatureWrapperRe.MatchString(html)
58+
if proj.HasSignature {
59+
if m := signatureIDRe.FindStringSubmatch(html); len(m) >= 2 {
60+
proj.SignatureID = m[1]
61+
}
62+
}
4863
}
4964

5065
parts := flattenParts(snapshot.Body)
@@ -128,17 +143,80 @@ func hasQuotedContent(html string) bool {
128143
return quoteWrapperRe.MatchString(html)
129144
}
130145

131-
// splitAtQuote splits an HTML body into the user-authored content and
146+
// SplitAtQuote splits an HTML body into the user-authored content and
132147
// the trailing reply/forward quote block. If no quote block is found,
133148
// quote is empty and body is the original html unchanged.
134-
func splitAtQuote(html string) (body, quote string) {
149+
func SplitAtQuote(html string) (body, quote string) {
135150
loc := quoteWrapperRe.FindStringIndex(html)
136151
if loc == nil {
137152
return html, ""
138153
}
139154
return html[:loc[0]], html[loc[0]:]
140155
}
141156

157+
// ── Exported signature HTML utilities ──
158+
// Used by both draft/patch.go (internal) and mail/signature_html.go (cross-package).
159+
160+
// signatureSpacingRe matches 1-2 empty-line divs before the signature.
161+
var signatureSpacingRe = regexp.MustCompile(
162+
`(?:<div[^>]*><div[^>]*><br></div></div>\s*){1,2}$`)
163+
164+
// SignatureSpacingRe returns the compiled regex for signature spacing detection.
165+
func SignatureSpacingRe() *regexp.Regexp { return signatureSpacingRe }
166+
167+
// SignatureSpacing returns the 2 empty-line divs placed before the signature,
168+
// matching the structure generated by the Lark mail editor.
169+
func SignatureSpacing() string {
170+
line := `<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto"><br></div></div>`
171+
return line + line
172+
}
173+
174+
// BuildSignatureHTML wraps signature content in the standard signature container div.
175+
func BuildSignatureHTML(sigID, content string) string {
176+
return `<div id="` + sigID + `" class="` + SignatureWrapperClass + `" style="padding-top:6px;padding-bottom:6px">` + content + `</div>`
177+
}
178+
179+
// FindMatchingCloseDiv finds the position after the closing </div> that matches
180+
// the <div at startPos, tracking nesting depth.
181+
func FindMatchingCloseDiv(html string, startPos int) int {
182+
depth := 0
183+
i := startPos
184+
for i < len(html) {
185+
if strings.HasPrefix(html[i:], "<div") {
186+
depth++
187+
i += 4
188+
} else if strings.HasPrefix(html[i:], "</div>") {
189+
depth--
190+
i += 6
191+
if depth == 0 {
192+
return i
193+
}
194+
} else {
195+
i++
196+
}
197+
}
198+
return len(html)
199+
}
200+
201+
// RemoveSignatureHTML removes the signature block and its preceding spacing from HTML.
202+
// Returns the HTML unchanged if no signature is found.
203+
func RemoveSignatureHTML(html string) string {
204+
loc := signatureWrapperRe.FindStringIndex(html)
205+
if loc == nil {
206+
return html
207+
}
208+
sigStart := loc[0]
209+
sigEnd := FindMatchingCloseDiv(html, sigStart)
210+
211+
// Extend backward to include preceding spacing.
212+
beforeSig := html[:sigStart]
213+
if spacingLoc := signatureSpacingRe.FindStringIndex(beforeSig); spacingLoc != nil {
214+
sigStart = spacingLoc[0]
215+
}
216+
217+
return html[:sigStart] + html[sigEnd:]
218+
}
219+
142220
func summarizeHTML(html string) string {
143221
trimmed := strings.TrimSpace(html)
144222
runes := []rune(trimmed)

shortcuts/mail/draft/projection_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ Content-Type: text/html; charset=UTF-8
100100

101101
func TestSplitAtQuoteReply(t *testing.T) {
102102
html := `<div>My reply</div><div class="history-quote-wrapper"><div>quoted</div></div>`
103-
body, quote := splitAtQuote(html)
103+
body, quote := SplitAtQuote(html)
104104
if body != `<div>My reply</div>` {
105105
t.Fatalf("body = %q", body)
106106
}
@@ -111,7 +111,7 @@ func TestSplitAtQuoteReply(t *testing.T) {
111111

112112
func TestSplitAtQuoteForward(t *testing.T) {
113113
html := `<div>note</div><div id="lark-mail-quote-cli123456" class="history-quote-wrapper"><div>quoted</div></div>`
114-
body, quote := splitAtQuote(html)
114+
body, quote := SplitAtQuote(html)
115115
if body != `<div>note</div>` {
116116
t.Fatalf("body = %q", body)
117117
}
@@ -122,7 +122,7 @@ func TestSplitAtQuoteForward(t *testing.T) {
122122

123123
func TestSplitAtQuoteNoQuote(t *testing.T) {
124124
html := `<div>no quote here</div>`
125-
body, quote := splitAtQuote(html)
125+
body, quote := SplitAtQuote(html)
126126
if body != html {
127127
t.Fatalf("body = %q, want original html", body)
128128
}
@@ -169,7 +169,7 @@ Content-Type: text/html; charset=UTF-8
169169

170170
func TestSplitAtQuoteFalsePositivePlainText(t *testing.T) {
171171
html := `<p>The CSS class history-quote-wrapper is used for quotes.</p>`
172-
body, quote := splitAtQuote(html)
172+
body, quote := SplitAtQuote(html)
173173
if body != html {
174174
t.Fatalf("body should be unchanged, got %q", body)
175175
}

0 commit comments

Comments
 (0)