Skip to content

Commit cf35d1e

Browse files
authored
feat(mail): auto-attach default signature on send/reply/forward (#1415)
* feat(mail): auto-attach default signature on send/reply/forward - Add exported PlainTextFromHTML wrapper in draft/htmltext.go - Add DefaultSendID/DefaultReplyID in signature/provider.go - Add noSignatureFlag, autoResolveSignatureID, validateNoSignatureConflict, injectPlainTextSignature in signature_compose.go; remove validateSignatureWithPlainText - mail_send, mail_draft_create: add --no-signature flag, auto-resolve default signature when no --signature-id given, inject plain-text sig in plain-text branch - mail_reply, mail_reply_all, mail_forward: same flag/validate changes + timing fix (resolveSignature moved to after senderEmail is finalized) - Update 5 reference docs: add --no-signature row, update --plain-text and --signature-id descriptions --------- Co-authored-by: xzcong0820 <278082089+xzcong0820@users.noreply.github.com>
1 parent fd16cf1 commit cf35d1e

18 files changed

Lines changed: 595 additions & 242 deletions

shortcuts/mail/draft/htmltext.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@ func isHTMLBlockBoundary(n *xhtml.Node) bool {
123123
}
124124
}
125125

126+
// PlainTextFromHTML is the exported wrapper over plainTextFromHTML, so the
127+
// mail package can render an HTML signature as a plain-text fallback when a
128+
// message body is sent in plain-text mode. The conversion logic is unchanged.
129+
func PlainTextFromHTML(raw string) string {
130+
return plainTextFromHTML(raw)
131+
}
132+
126133
// bodyLooksLikeHTML reports whether raw appears to contain HTML markup.
127134
// This is intentionally heuristic: it exists to reject obvious plain-text
128135
// input when a draft's authored body is text/html.

shortcuts/mail/draft/htmltext_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,10 @@ func TestIsHTMLNonTextTag(t *testing.T) {
102102
})
103103
}
104104
}
105+
106+
func TestPlainTextFromHTMLExported(t *testing.T) {
107+
got := PlainTextFromHTML("<p>Hello world</p>")
108+
if !strings.Contains(got, "Hello world") {
109+
t.Fatalf("PlainTextFromHTML: expected to contain \"Hello world\", got %q", got)
110+
}
111+
}

shortcuts/mail/mail_draft_create.go

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ var MailDraftCreate = common.Shortcut{
5656
{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed."},
5757
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's subject/body/to/cc/bcc/attachments are merged with user-supplied flags (user flags win). Requires --as user."},
5858
signatureFlag,
59+
noSignatureFlag,
5960
priorityFlag,
6061
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
6162
showLintDetailsFlag,
@@ -92,7 +93,7 @@ var MailDraftCreate = common.Shortcut{
9293
if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" {
9394
return mailValidationParamError("--subject", "--subject is required; pass the final email subject (or use --template-id)")
9495
}
95-
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
96+
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
9697
return err
9798
}
9899
if err := validateEventFlags(runtime); err != nil {
@@ -180,12 +181,22 @@ var MailDraftCreate = common.Shortcut{
180181
if strings.TrimSpace(input.Body) == "" {
181182
return mailValidationParamError("--body", "effective body is empty after applying template; pass --body explicitly")
182183
}
183-
sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from"))
184+
signatureID := runtime.Str("signature-id")
185+
noSignature := runtime.Bool("no-signature")
186+
senderEmail := resolveComposeSenderEmail(runtime)
187+
// Auto-resolve default signature when neither --no-signature nor --signature-id is set.
188+
if noSignature {
189+
signatureID = ""
190+
} else if signatureID == "" {
191+
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, false)
192+
}
193+
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
194+
runtime.Str("signature-id") != "", !input.PlainText)
184195
if err != nil {
185196
return err
186197
}
187198
rawEML, lintApplied, lintBlocked, err := buildRawEMLForDraftCreate(ctx, runtime, input, sigResult, priority,
188-
templateLargeAttachmentIDs, mailboxID, templateID, templateInlineAttachments, templateSmallAttachments)
199+
templateLargeAttachmentIDs, mailboxID, templateID, templateInlineAttachments, templateSmallAttachments, senderEmail)
189200
if err != nil {
190201
return err
191202
}
@@ -241,13 +252,19 @@ func buildRawEMLForDraftCreate(
241252
mailboxID, templateID string,
242253
templateInlineAttachments []templateInlineRef,
243254
templateSmallAttachments []templateAttachmentRef,
255+
senderEmailHint string,
244256
) (rawEMLOut string, lintApplied, lintBlocked []lint.Finding, err error) {
245257
// Initialise lint findings as empty (non-nil) slices so callers can
246258
// surface them through the envelope unconditionally even on the
247259
// plain-text branch.
248260
lintApplied, lintBlocked = emptyLintFindings()
249261

250-
senderEmail := resolveComposeSenderEmail(runtime)
262+
// Use the pre-resolved senderEmail when available (avoids a duplicate
263+
// profile API call when Execute already fetched it for auto-resolve).
264+
senderEmail := senderEmailHint
265+
if senderEmail == "" {
266+
senderEmail = resolveComposeSenderEmail(runtime)
267+
}
251268
if senderEmail == "" {
252269
return "", lintApplied, lintBlocked, mailValidationParamError("--from", "unable to determine sender email; please specify --from explicitly")
253270
}
@@ -290,7 +307,7 @@ func buildRawEMLForDraftCreate(
290307
var composedHTMLBody string
291308
var composedTextBody string
292309
if input.PlainText {
293-
composedTextBody = input.Body
310+
composedTextBody = injectPlainTextSignature(input.Body, sigResult)
294311
bld = bld.TextBody([]byte(composedTextBody))
295312
} else if bodyIsHTML(input.Body) || sigResult != nil {
296313
htmlBody := input.Body

shortcuts/mail/mail_draft_create_test.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func TestBuildRawEMLForDraftCreate_ResolvesLocalImages(t *testing.T) {
6262
Body: `<p>Hello</p><p><img src="./test_image.png" /></p>`,
6363
}
6464

65-
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
65+
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
6666
if err != nil {
6767
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
6868
}
@@ -88,7 +88,7 @@ func TestBuildRawEMLForDraftCreate_NoLocalImages(t *testing.T) {
8888
Body: `<p>Hello <b>world</b></p>`,
8989
}
9090

91-
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
91+
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
9292
if err != nil {
9393
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
9494
}
@@ -124,7 +124,7 @@ func TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit(t *testing.T) {
124124
Attach: "./big.txt",
125125
}
126126

127-
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
127+
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
128128
if err == nil {
129129
t.Fatal("expected size limit error when auto-resolved image + attachment exceed 25MB")
130130
}
@@ -145,7 +145,7 @@ func TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError(t *testing.T) {
145145
Inline: `[{"cid":"orphan","file_path":"./unused.png"}]`,
146146
}
147147

148-
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
148+
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
149149
if err == nil {
150150
t.Fatal("expected error for orphaned --inline CID not referenced in body")
151151
}
@@ -166,7 +166,7 @@ func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) {
166166
Inline: `[{"cid":"present","file_path":"./present.png"}]`,
167167
}
168168

169-
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
169+
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
170170
if err == nil {
171171
t.Fatal("expected error for missing CID reference")
172172
}
@@ -183,7 +183,7 @@ func TestBuildRawEMLForDraftCreate_WithPriority(t *testing.T) {
183183
Body: `<p>Hello</p>`,
184184
}
185185

186-
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "1", nil, "", "", nil, nil)
186+
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "1", nil, "", "", nil, nil, "")
187187
if err != nil {
188188
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
189189
}
@@ -201,7 +201,7 @@ func TestBuildRawEMLForDraftCreate_NoPriority(t *testing.T) {
201201
Body: `<p>Hello</p>`,
202202
}
203203

204-
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
204+
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
205205
if err != nil {
206206
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
207207
}
@@ -237,7 +237,7 @@ func TestBuildRawEMLForDraftCreate_RequestReceiptAddsHeader(t *testing.T) {
237237
}
238238

239239
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(),
240-
newRuntimeWithFromAndRequestReceipt("sender@example.com", true), input, nil, "", nil, "", "", nil, nil)
240+
newRuntimeWithFromAndRequestReceipt("sender@example.com", true), input, nil, "", nil, "", "", nil, nil, "")
241241
if err != nil {
242242
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
243243
}
@@ -260,7 +260,7 @@ func TestBuildRawEMLForDraftCreate_RequestReceiptOmittedByDefault(t *testing.T)
260260
}
261261

262262
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(),
263-
newRuntimeWithFromAndRequestReceipt("sender@example.com", false), input, nil, "", nil, "", "", nil, nil)
263+
newRuntimeWithFromAndRequestReceipt("sender@example.com", false), input, nil, "", nil, "", "", nil, nil, "")
264264
if err != nil {
265265
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
266266
}
@@ -283,7 +283,7 @@ func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
283283
PlainText: true,
284284
}
285285

286-
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
286+
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
287287
if err != nil {
288288
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
289289
}
@@ -304,7 +304,7 @@ func TestBuildRawEMLForDraftCreate_WithCalendarEvent(t *testing.T) {
304304
Body: "<p>Please join us</p>",
305305
}
306306

307-
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), rt, input, nil, "", nil, "", "", nil, nil)
307+
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), rt, input, nil, "", nil, "", "", nil, nil, "")
308308
if err != nil {
309309
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
310310
}

shortcuts/mail/mail_draft_edit.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ var MailDraftEdit = common.Shortcut{
134134
for i := range patch.Ops {
135135
switch patch.Ops[i].Op {
136136
case "insert_signature":
137-
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail)
137+
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail, true, true)
138138
if sigErr != nil {
139139
return sigErr
140140
}

shortcuts/mail/mail_forward.go

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ var MailForward = common.Shortcut{
4545
{Name: "subject", Desc: "Optional. Override the auto-generated Fw: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
4646
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are merged into the forward draft (template values appended to user flags / forward-derived values; no de-duplication)."},
4747
signatureFlag,
48+
noSignatureFlag,
4849
priorityFlag,
4950
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
5051
showLintDetailsFlag},
@@ -96,7 +97,7 @@ var MailForward = common.Shortcut{
9697
return err
9798
}
9899
}
99-
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
100+
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
100101
return err
101102
}
102103
if err := validateEventFlags(runtime); err != nil {
@@ -127,12 +128,7 @@ var MailForward = common.Shortcut{
127128
return err
128129
}
129130

130-
signatureID := runtime.Str("signature-id")
131131
mailboxID := resolveComposeMailboxID(runtime)
132-
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
133-
if sigErr != nil {
134-
return sigErr
135-
}
136132
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
137133
if err != nil {
138134
return mailDecorateProblemMessage(err, "failed to fetch original message")
@@ -156,6 +152,18 @@ var MailForward = common.Shortcut{
156152
senderEmail = orig.headTo
157153
}
158154

155+
// Signature ID is resolved here (after senderEmail is finalised) so DefaultReplyID
156+
// matches the correct usage. The actual image download in resolveSignature is deferred
157+
// to after applyTemplate so the final plainText value (which a template can override
158+
// via IsPlainTextMode) is used for the downloadImages decision.
159+
signatureID := runtime.Str("signature-id")
160+
noSignature := runtime.Bool("no-signature")
161+
if noSignature {
162+
signatureID = ""
163+
} else if signatureID == "" {
164+
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, true /*isReply*/)
165+
}
166+
159167
// --template-id merge (§5.5 Q1-Q5).
160168
var templateLargeAttachmentIDs []string
161169
var templateInlineAttachments []templateInlineRef
@@ -198,6 +206,14 @@ var MailForward = common.Shortcut{
198206
"bccs_count": countAddresses(bccFlag),
199207
})
200208
}
209+
// Resolve signature after template processing so plainText reflects any IsPlainTextMode
210+
// override from the template. This avoids downloading HTML signature images when the
211+
// template forces plain-text mode, which could cause CDN 403/5xx or timeout errors.
212+
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
213+
runtime.Str("signature-id") != "", !plainText)
214+
if sigErr != nil {
215+
return sigErr
216+
}
201217
subjectOverride := strings.TrimSpace(runtime.Str("subject"))
202218

203219
// Post-merge recipient check for --confirm-send + --template-id:
@@ -310,7 +326,7 @@ var MailForward = common.Shortcut{
310326
return err
311327
}
312328
} else {
313-
composedTextBody = buildForwardedMessage(&orig, body)
329+
composedTextBody = buildForwardedMessage(&orig, injectPlainTextSignature(body, sigResult))
314330
bld = bld.TextBody([]byte(composedTextBody))
315331
}
316332
// Embed template SMALL non-inline attachments regardless of body mode.

shortcuts/mail/mail_reply.go

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ var MailReply = common.Shortcut{
4242
{Name: "subject", Desc: "Optional. Override the auto-generated Re: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
4343
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are appended to the reply-derived values (no de-duplication; see warning in Execute output)."},
4444
signatureFlag,
45+
noSignatureFlag,
4546
priorityFlag,
4647
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
4748
showLintDetailsFlag},
@@ -93,7 +94,7 @@ var MailReply = common.Shortcut{
9394
if err := validateSendTime(runtime); err != nil {
9495
return err
9596
}
96-
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
97+
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
9798
return err
9899
}
99100
if err := validateEventFlags(runtime); err != nil {
@@ -129,12 +130,7 @@ var MailReply = common.Shortcut{
129130
return err
130131
}
131132

132-
signatureID := runtime.Str("signature-id")
133133
mailboxID := resolveComposeMailboxID(runtime)
134-
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
135-
if sigErr != nil {
136-
return sigErr
137-
}
138134
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
139135
if err != nil {
140136
return mailDecorateProblemMessage(err, "failed to fetch original message")
@@ -156,6 +152,18 @@ var MailReply = common.Shortcut{
156152
senderEmail = orig.headTo
157153
}
158154

155+
// Signature ID is resolved here (after senderEmail is finalised) so DefaultReplyID
156+
// matches the correct usage. The actual image download in resolveSignature is deferred
157+
// to after applyTemplate so the final plainText value (which a template can override
158+
// via IsPlainTextMode) is used for the downloadImages decision.
159+
signatureID := runtime.Str("signature-id")
160+
noSignature := runtime.Bool("no-signature")
161+
if noSignature {
162+
signatureID = ""
163+
} else if signatureID == "" {
164+
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, true /*isReply*/)
165+
}
166+
159167
replyTo := orig.replyTo
160168
if replyTo == "" {
161169
replyTo = orig.headFrom
@@ -208,6 +216,14 @@ var MailReply = common.Shortcut{
208216
"bccs_count": countAddresses(bccFlag),
209217
})
210218
}
219+
// Resolve signature after template processing so plainText reflects any IsPlainTextMode
220+
// override from the template. This avoids downloading HTML signature images when the
221+
// template forces plain-text mode, which could cause CDN 403/5xx or timeout errors.
222+
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
223+
runtime.Str("signature-id") != "", !plainText)
224+
if sigErr != nil {
225+
return sigErr
226+
}
211227
// --subject (explicit override) takes precedence over auto-generated.
212228
subjectOverride := strings.TrimSpace(runtime.Str("subject"))
213229

@@ -311,7 +327,7 @@ var MailReply = common.Shortcut{
311327
return err
312328
}
313329
} else {
314-
composedTextBody = bodyStr + quoted
330+
composedTextBody = injectPlainTextSignature(bodyStr, sigResult) + quoted
315331
bld = bld.TextBody([]byte(composedTextBody))
316332
}
317333
// Embed template SMALL non-inline attachments regardless of body mode.

0 commit comments

Comments
 (0)