From f58852a4fe078fa4713b0bf2ee39f2a0cedfbfd6 Mon Sep 17 00:00:00 2001
From: "lijiayi.2333"
Date: Sat, 6 Jun 2026 13:32:08 +0800
Subject: [PATCH 1/2] feat: add default signatures to mail send
Append the sender's default send signature in +send while keeping --signature-id as an explicit override and adding --no-signature for opt-out. Plain-text bodies now receive a rendered text signature without upgrading to HTML.
sprint: S1
---
shortcuts/mail/draft/htmltext.go | 6 +
shortcuts/mail/mail_lint_writepath_test.go | 1 +
.../mail_request_receipt_integration_test.go | 2 +
shortcuts/mail/mail_send.go | 26 +-
.../mail/mail_send_confirm_output_test.go | 3 +
shortcuts/mail/mail_send_signature_test.go | 245 ++++++++++++++++++
shortcuts/mail/mail_template_shortcut_test.go | 2 +
shortcuts/mail/signature_compose.go | 130 ++++++++--
shortcuts/mail/signature_compose_test.go | 75 ++++++
skills/lark-mail/references/lark-mail-send.md | 9 +-
10 files changed, 468 insertions(+), 31 deletions(-)
create mode 100644 shortcuts/mail/mail_send_signature_test.go
diff --git a/shortcuts/mail/draft/htmltext.go b/shortcuts/mail/draft/htmltext.go
index 0ef9c17de..c9cd6c49e 100644
--- a/shortcuts/mail/draft/htmltext.go
+++ b/shortcuts/mail/draft/htmltext.go
@@ -84,6 +84,12 @@ func plainTextFromHTML(raw string) string {
return strings.Join(out, "\n")
}
+// PlainTextFromHTML exposes the draft package's conservative HTML-to-text
+// conversion for compose helpers that need to keep a text/plain body.
+func PlainTextFromHTML(raw string) string {
+ return plainTextFromHTML(raw)
+}
+
func bufLastByte(buf *bytes.Buffer) byte {
if buf.Len() == 0 {
return 0
diff --git a/shortcuts/mail/mail_lint_writepath_test.go b/shortcuts/mail/mail_lint_writepath_test.go
index 5b145d87a..97712ea35 100644
--- a/shortcuts/mail/mail_lint_writepath_test.go
+++ b/shortcuts/mail/mail_lint_writepath_test.go
@@ -486,6 +486,7 @@ func TestMailSend_WritePathLintAutofixesFontInEML(t *testing.T) {
"--to", "alice@example.com",
"--subject", "Send",
"--body", `payload`,
+ "--no-signature",
"--show-lint-details",
}, f, stdout)
if err != nil {
diff --git a/shortcuts/mail/mail_request_receipt_integration_test.go b/shortcuts/mail/mail_request_receipt_integration_test.go
index 32d6f76ce..4a53a37a9 100644
--- a/shortcuts/mail/mail_request_receipt_integration_test.go
+++ b/shortcuts/mail/mail_request_receipt_integration_test.go
@@ -127,6 +127,7 @@ func TestMailSend_RequestReceiptAddsHeader_Integration(t *testing.T) {
"--to", "bob@example.com",
"--subject", "hi",
"--body", "please confirm",
+ "--no-signature",
"--request-receipt",
"--confirm-send",
}, f, stdout); err != nil {
@@ -153,6 +154,7 @@ func TestMailSend_RequestReceiptNoSender_FailsValidation(t *testing.T) {
"--to", "bob@example.com",
"--subject", "hi",
"--body", "body",
+ "--no-signature",
"--request-receipt",
"--confirm-send",
}, f, stdout)
diff --git a/shortcuts/mail/mail_send.go b/shortcuts/mail/mail_send.go
index b536aa827..b032dc07e 100644
--- a/shortcuts/mail/mail_send.go
+++ b/shortcuts/mail/mail_send.go
@@ -40,6 +40,7 @@ var MailSend = common.Shortcut{
{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."},
{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."},
signatureFlag,
+ noSignatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
showLintDetailsFlag},
@@ -58,7 +59,15 @@ var MailSend = common.Shortcut{
Desc("Fetch template to merge with compose flags (subject/body/to/cc/bcc/attachments).")
}
api = api.GET(mailboxPath(mailboxID, "profile")).
- POST(mailboxPath(mailboxID, "drafts")).
+ Desc("Resolve sender email for From header and signature matching.")
+ if !runtime.Bool("no-signature") {
+ desc := "Resolve default send signature for the sender."
+ if strings.TrimSpace(runtime.Str("signature-id")) != "" {
+ desc = "Resolve explicit signature by ID."
+ }
+ api = api.GET(mailboxPath(mailboxID, "settings", "signatures")).Desc(desc)
+ }
+ api = api.POST(mailboxPath(mailboxID, "drafts")).
Body(map[string]interface{}{
"raw": "",
"_preview": map[string]interface{}{
@@ -98,7 +107,7 @@ var MailSend = common.Shortcut{
if err := validateSendTime(runtime); err != nil {
return err
}
- if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
+ if err := validateSignatureFlags(runtime.Str("signature-id"), runtime.Bool("no-signature")); err != nil {
return err
}
// Resolve the body content first (reading --body-file if set) so
@@ -195,7 +204,8 @@ var MailSend = common.Shortcut{
}
}
- sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail)
+ signatureHTMLMode := !plainText && bodyIsHTML(body)
+ sigResult, err := resolveSignatureForSend(ctx, runtime, mailboxID, signatureID, senderEmail, runtime.Bool("no-signature"), signatureHTMLMode)
if err != nil {
return err
}
@@ -230,14 +240,10 @@ var MailSend = common.Shortcut{
// `lint_applied[]` / `original_blocked[]` even on the plain-text path.
lintApplied, lintBlocked := emptyLintEnvelopeFields()
if plainText {
- composedTextBody = body
+ composedTextBody = injectSignatureIntoPlainText(body, sigResult)
bld = bld.TextBody([]byte(composedTextBody))
- } else if bodyIsHTML(body) || sigResult != nil {
- // If signature is requested on plain-text body, auto-upgrade to HTML.
+ } else if bodyIsHTML(body) {
htmlBody := body
- if !bodyIsHTML(body) {
- htmlBody = buildBodyDiv(body, false)
- }
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
if resolveErr != nil {
return mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr)
@@ -275,7 +281,7 @@ var MailSend = common.Shortcut{
return err
}
} else {
- composedTextBody = body
+ composedTextBody = injectSignatureIntoPlainText(body, sigResult)
bld = bld.TextBody([]byte(composedTextBody))
}
// Embed template SMALL non-inline attachments via AddAttachment.
diff --git a/shortcuts/mail/mail_send_confirm_output_test.go b/shortcuts/mail/mail_send_confirm_output_test.go
index 10f89b030..2a9641f2e 100644
--- a/shortcuts/mail/mail_send_confirm_output_test.go
+++ b/shortcuts/mail/mail_send_confirm_output_test.go
@@ -149,6 +149,7 @@ func TestMailSendConfirmSendOutputsAutomationDisable(t *testing.T) {
"--to", "alice@example.com",
"--subject", "hello",
"--body", "world",
+ "--no-signature",
"--confirm-send",
}, f, stdout)
if err != nil {
@@ -203,6 +204,7 @@ func TestMailSendSaveDraftOutputsReference(t *testing.T) {
"--to", "alice@example.com",
"--subject", "hello",
"--body", "world",
+ "--no-signature",
}, f, stdout)
if err != nil {
t.Fatalf("save draft failed: %v", err)
@@ -246,6 +248,7 @@ func TestMailSend_WithCalendarEventEmbedded(t *testing.T) {
"--event-summary", "Team Sync",
"--event-start", "2026-05-10T10:00+08:00",
"--event-end", "2026-05-10T11:00+08:00",
+ "--no-signature",
}, f, stdout)
if err != nil {
t.Fatalf("mail send with calendar failed: %v", err)
diff --git a/shortcuts/mail/mail_send_signature_test.go b/shortcuts/mail/mail_send_signature_test.go
new file mode 100644
index 000000000..ae795e402
--- /dev/null
+++ b/shortcuts/mail/mail_send_signature_test.go
@@ -0,0 +1,245 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package mail
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/larksuite/cli/internal/httpmock"
+)
+
+func registerSendSignatureList(reg *httpmock.Registry, mailboxID string, signatures []map[string]interface{}, usages []map[string]interface{}) {
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: mailboxPath(mailboxID, "settings", "signatures"),
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "signatures": signatures,
+ "usages": usages,
+ },
+ },
+ })
+}
+
+func registerSendAsForSignature(reg *httpmock.Registry, mailboxID, email string) {
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: mailboxPath(mailboxID, "settings", "send_as"),
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "sendable_addresses": []map[string]interface{}{
+ {"email_address": email, "name": "Sender"},
+ },
+ },
+ },
+ })
+}
+
+func registerDraftCaptureStubsForMailbox(reg *httpmock.Registry, mailboxID string) *httpmock.Stub {
+ createStub := &httpmock.Stub{
+ Method: "POST",
+ URL: mailboxPath(mailboxID, "drafts"),
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{"draft_id": "draft_001"},
+ },
+ }
+ reg.Register(createStub)
+ return createStub
+}
+
+func TestMailSendAppendsDefaultSignatureToHTMLBody(t *testing.T) {
+ f, stdout, _, reg := mailShortcutTestFactoryWithSendScope(t)
+ mailboxID := "sig-html-box"
+ sender := "sender@example.com"
+ registerSendSignatureList(reg, mailboxID,
+ []map[string]interface{}{
+ {
+ "id": "sig_default",
+ "name": "Default",
+ "signature_type": "USER",
+ "signature_device": "PC",
+ "content": `Best,
Alice
`,
+ },
+ },
+ []map[string]interface{}{
+ {"email_address": sender, "send_mail_signature_id": "sig_default"},
+ },
+ )
+ registerSendAsForSignature(reg, mailboxID, sender)
+ createStub := registerDraftCaptureStubsForMailbox(reg, mailboxID)
+
+ err := runMountedMailShortcut(t, MailSend, []string{
+ "+send",
+ "--mailbox", mailboxID,
+ "--from", sender,
+ "--to", "bob@example.com",
+ "--subject", "hello",
+ "--body", "Hello
",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("send failed: %v", err)
+ }
+
+ raw := decodeCapturedRawEML(t, createStub.CapturedBody)
+ if !strings.Contains(raw, "lark-mail-signature") {
+ t.Fatalf("HTML EML missing signature wrapper:\n%s", raw)
+ }
+ if !strings.Contains(raw, "Best") || !strings.Contains(raw, "Alice") {
+ t.Fatalf("HTML EML missing signature content:\n%s", raw)
+ }
+}
+
+func TestMailSendAppendsDefaultSignatureToPlainTextBody(t *testing.T) {
+ f, stdout, _, reg := mailShortcutTestFactoryWithSendScope(t)
+ mailboxID := "sig-text-box"
+ sender := "sender@example.com"
+ registerSendSignatureList(reg, mailboxID,
+ []map[string]interface{}{
+ {
+ "id": "sig_text",
+ "name": "Text",
+ "signature_type": "USER",
+ "signature_device": "PC",
+ "content": `Best,
Alice
`,
+ },
+ },
+ []map[string]interface{}{
+ {"email_address": sender, "send_mail_signature_id": "sig_text"},
+ },
+ )
+ registerSendAsForSignature(reg, mailboxID, sender)
+ createStub := registerDraftCaptureStubsForMailbox(reg, mailboxID)
+
+ err := runMountedMailShortcut(t, MailSend, []string{
+ "+send",
+ "--mailbox", mailboxID,
+ "--from", sender,
+ "--to", "bob@example.com",
+ "--subject", "hello",
+ "--body", "Hello",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("send failed: %v", err)
+ }
+
+ raw := decodeCapturedRawEML(t, createStub.CapturedBody)
+ if !strings.Contains(raw, "Content-Type: text/plain") {
+ t.Fatalf("plain body should remain text/plain:\n%s", raw)
+ }
+ if strings.Contains(raw, "lark-mail-signature") || strings.Contains(raw, "Content-Type: text/html") {
+ t.Fatalf("plain body should not contain HTML signature wrapper:\n%s", raw)
+ }
+ if !strings.Contains(raw, "Hello") || !strings.Contains(raw, "Best") || !strings.Contains(raw, "Alice") {
+ t.Fatalf("plain EML missing body or signature text:\n%s", raw)
+ }
+}
+
+func TestMailSendNoSignatureSkipsSignatureLookup(t *testing.T) {
+ f, stdout, _, reg := mailShortcutTestFactoryWithSendScope(t)
+ mailboxID := "no-sig-box"
+ createStub := registerDraftCaptureStubsForMailbox(reg, mailboxID)
+
+ err := runMountedMailShortcut(t, MailSend, []string{
+ "+send",
+ "--mailbox", mailboxID,
+ "--from", "sender@example.com",
+ "--to", "bob@example.com",
+ "--subject", "hello",
+ "--body", "Hello
",
+ "--no-signature",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("send failed: %v", err)
+ }
+
+ raw := decodeCapturedRawEML(t, createStub.CapturedBody)
+ if strings.Contains(raw, "lark-mail-signature") || strings.Contains(raw, "Best") {
+ t.Fatalf("--no-signature should keep EML signature-free:\n%s", raw)
+ }
+}
+
+func TestMailSendExplicitSignatureOverridesDefault(t *testing.T) {
+ f, stdout, _, reg := mailShortcutTestFactoryWithSendScope(t)
+ mailboxID := "sig-explicit-box"
+ sender := "sender@example.com"
+ registerSendSignatureList(reg, mailboxID,
+ []map[string]interface{}{
+ {
+ "id": "sig_default",
+ "name": "Default",
+ "signature_type": "USER",
+ "signature_device": "PC",
+ "content": `Default Signature
`,
+ },
+ {
+ "id": "sig_explicit",
+ "name": "Explicit",
+ "signature_type": "USER",
+ "signature_device": "PC",
+ "content": `Explicit Signature
`,
+ },
+ },
+ []map[string]interface{}{
+ {"email_address": sender, "send_mail_signature_id": "sig_default"},
+ },
+ )
+ registerSendAsForSignature(reg, mailboxID, sender)
+ createStub := registerDraftCaptureStubsForMailbox(reg, mailboxID)
+
+ err := runMountedMailShortcut(t, MailSend, []string{
+ "+send",
+ "--mailbox", mailboxID,
+ "--from", sender,
+ "--to", "bob@example.com",
+ "--subject", "hello",
+ "--body", "Hello
",
+ "--signature-id", "sig_explicit",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("send failed: %v", err)
+ }
+
+ raw := decodeCapturedRawEML(t, createStub.CapturedBody)
+ if !strings.Contains(raw, "Explicit Signature") {
+ t.Fatalf("explicit signature missing from EML:\n%s", raw)
+ }
+ if strings.Contains(raw, "Default Signature") {
+ t.Fatalf("default signature should not be used when --signature-id is set:\n%s", raw)
+ }
+}
+
+func TestMailSendDryRunShowsSignatureLookupUnlessDisabled(t *testing.T) {
+ f, stdout, _, _ := mailShortcutTestFactoryWithSendScope(t)
+
+ if err := runMountedMailShortcut(t, MailSend, []string{
+ "+send",
+ "--to", "bob@example.com",
+ "--subject", "hello",
+ "--body", "Hello
",
+ "--dry-run",
+ }, f, stdout); err != nil {
+ t.Fatalf("dry-run failed: %v", err)
+ }
+ if !strings.Contains(stdout.String(), "/settings/signatures") {
+ t.Fatalf("default dry-run should include signatures lookup:\n%s", stdout.String())
+ }
+
+ if err := runMountedMailShortcut(t, MailSend, []string{
+ "+send",
+ "--to", "bob@example.com",
+ "--subject", "hello",
+ "--body", "Hello
",
+ "--no-signature",
+ "--dry-run",
+ }, f, stdout); err != nil {
+ t.Fatalf("dry-run with --no-signature failed: %v", err)
+ }
+ if strings.Contains(stdout.String(), "/settings/signatures") {
+ t.Fatalf("--no-signature dry-run should omit signatures lookup:\n%s", stdout.String())
+ }
+}
diff --git a/shortcuts/mail/mail_template_shortcut_test.go b/shortcuts/mail/mail_template_shortcut_test.go
index a7cfff1f2..c204f996d 100644
--- a/shortcuts/mail/mail_template_shortcut_test.go
+++ b/shortcuts/mail/mail_template_shortcut_test.go
@@ -1042,6 +1042,7 @@ func TestFetchTemplateAttachmentURLs_FailedReasons(t *testing.T) {
"--subject", "s",
"--body", "b
",
"--template-id", "33",
+ "--no-signature",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "download URL not returned") {
t.Errorf("expected download-URL-missing error (with failed_reasons warning), got %v", err)
@@ -1141,6 +1142,7 @@ func TestMailSend_TemplateIDAppliesInlineAndSmall(t *testing.T) {
"--subject", "override-subj",
"--body", "user body
",
"--template-id", "42",
+ "--no-signature",
}, f, stdout)
if err != nil {
t.Fatalf("+send --template-id failed: %v", err)
diff --git a/shortcuts/mail/signature_compose.go b/shortcuts/mail/signature_compose.go
index ac7f13cde..31c82120a 100644
--- a/shortcuts/mail/signature_compose.go
+++ b/shortcuts/mail/signature_compose.go
@@ -9,6 +9,7 @@ import (
"net/http"
"net/url"
"path/filepath"
+ "regexp"
"strings"
"time"
@@ -25,17 +26,29 @@ var signatureFlag = common.Flag{
Desc: "Optional. Signature ID to append after body content. Run `mail +signature` to list available signatures.",
}
+var noSignatureFlag = common.Flag{
+ Name: "no-signature",
+ Type: "bool",
+ Desc: "Skip automatic default signature lookup and do not append a signature. Mutually exclusive with --signature-id.",
+}
+
// signatureResult holds the pre-processed signature data ready for HTML injection.
type signatureResult struct {
- ID string
- RenderedContent string
- Images []draftpkg.SignatureImage
+ ID string
+ RenderedContent string
+ RenderedTextContent string
+ Images []draftpkg.SignatureImage
}
// resolveSignature fetches, interpolates, and downloads images for a signature.
// fromEmail is the --from address (may be an alias); used to match the correct
// sender identity for template interpolation. Pass "" to use the primary address.
func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailboxID, signatureID, fromEmail string) (*signatureResult, error) {
+ return resolveSignatureWithImages(ctx, runtime, mailboxID, signatureID, fromEmail, true)
+}
+
+func resolveSignatureWithImages(ctx context.Context, runtime *common.RuntimeContext, mailboxID, signatureID, fromEmail string, includeImages bool) (*signatureResult, error) {
+ signatureID = strings.TrimSpace(signatureID)
if signatureID == "" {
return nil, nil
}
@@ -49,33 +62,79 @@ func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailb
lang := resolveLang(runtime)
senderName, senderEmail := resolveSenderInfo(runtime, mailboxID, fromEmail)
rendered := signature.InterpolateTemplate(sig, lang, senderName, senderEmail)
+ renderedText := renderSignatureText(rendered, lang)
// Download signature inline images. The file_key field contains a
// direct download URL provided by the mail backend.
var images []draftpkg.SignatureImage
- for _, img := range sig.Images {
- if img.DownloadURL == "" || img.CID == "" {
- continue
- }
- data, ct, err := downloadSignatureImage(runtime, img.DownloadURL, img.ImageName)
- if err != nil {
- return nil, mailDecorateProblemMessage(err, "failed to download signature image %s", img.ImageName)
+ if includeImages {
+ for _, img := range sig.Images {
+ if img.DownloadURL == "" || img.CID == "" {
+ continue
+ }
+ data, ct, err := downloadSignatureImage(runtime, img.DownloadURL, img.ImageName)
+ if err != nil {
+ return nil, mailDecorateProblemMessage(err, "failed to download signature image %s", img.ImageName)
+ }
+ images = append(images, draftpkg.SignatureImage{
+ CID: img.CID,
+ ContentType: ct,
+ FileName: img.ImageName,
+ Data: data,
+ })
}
- images = append(images, draftpkg.SignatureImage{
- CID: img.CID,
- ContentType: ct,
- FileName: img.ImageName,
- Data: data,
- })
}
return &signatureResult{
- ID: sig.ID,
- RenderedContent: rendered,
- Images: images,
+ ID: sig.ID,
+ RenderedContent: rendered,
+ RenderedTextContent: renderedText,
+ Images: images,
}, nil
}
+func resolveSignatureForSend(ctx context.Context, runtime *common.RuntimeContext, mailboxID, explicitID, senderEmail string, noSignature, includeImages bool) (*signatureResult, error) {
+ if noSignature {
+ return nil, nil
+ }
+ sigID := strings.TrimSpace(explicitID)
+ if sigID == "" {
+ resp, err := signature.ListAll(runtime, mailboxID)
+ if err != nil {
+ return nil, mailDecorateProblemMessage(err, "failed to resolve default mail signature")
+ }
+ sigID = selectDefaultSendSignatureID(resp, senderEmail)
+ }
+ if sigID == "" {
+ return nil, nil
+ }
+ return resolveSignatureWithImages(ctx, runtime, mailboxID, sigID, senderEmail, includeImages)
+}
+
+func selectDefaultSendSignatureID(resp *signature.GetSignaturesResponse, senderEmail string) string {
+ if resp == nil {
+ return ""
+ }
+ sender := strings.ToLower(strings.TrimSpace(senderEmail))
+ var only string
+ count := 0
+ for _, usage := range resp.Usages {
+ id := strings.TrimSpace(usage.SendMailSignatureID)
+ if id == "" || id == "0" {
+ continue
+ }
+ if sender != "" && strings.EqualFold(strings.TrimSpace(usage.EmailAddress), sender) {
+ return id
+ }
+ only = id
+ count++
+ }
+ if sender == "" && count == 1 {
+ return only
+ }
+ return ""
+}
+
// injectSignatureIntoBody inserts signature HTML into the body, placing
// it right after the user-authored region and before any system-managed
// tail (large attachment card or quote block). Any existing signature is
@@ -92,6 +151,28 @@ func injectSignatureIntoBody(bodyHTML string, sig *signatureResult) string {
return draftpkg.PlaceSignatureBeforeSystemTail(bodyHTML, sigBlock)
}
+func injectSignatureIntoPlainText(body string, sig *signatureResult) string {
+ if sig == nil || strings.TrimSpace(sig.RenderedTextContent) == "" {
+ return body
+ }
+ body = strings.TrimRight(body, "\r\n")
+ text := strings.TrimSpace(sig.RenderedTextContent)
+ if body == "" {
+ return text
+ }
+ return body + "\n\n" + text
+}
+
+var signatureImageTagRe = regexp.MustCompile(`(?i)
]*>`)
+
+func renderSignatureText(renderedHTML, lang string) string {
+ placeholder := "[image]"
+ if strings.HasPrefix(lang, "zh") {
+ placeholder = "[图片]"
+ }
+ return draftpkg.PlainTextFromHTML(signatureImageTagRe.ReplaceAllString(renderedHTML, placeholder))
+}
+
// addSignatureImagesToBuilder adds signature inline images to the EML builder.
func addSignatureImagesToBuilder(bld emlbuilder.Builder, sig *signatureResult) emlbuilder.Builder {
if sig == nil {
@@ -255,3 +336,14 @@ func validateSignatureWithPlainText(plainText bool, signatureID string) error {
}
return nil
}
+
+func validateSignatureFlags(signatureID string, noSignature bool) error {
+ if noSignature && strings.TrimSpace(signatureID) != "" {
+ return mailValidationError("--no-signature and --signature-id are mutually exclusive").
+ WithParams(
+ mailInvalidParam("--no-signature", "mutually exclusive with --signature-id"),
+ mailInvalidParam("--signature-id", "mutually exclusive with --no-signature"),
+ )
+ }
+ return nil
+}
diff --git a/shortcuts/mail/signature_compose_test.go b/shortcuts/mail/signature_compose_test.go
index 51bf63f97..f179b5ab3 100644
--- a/shortcuts/mail/signature_compose_test.go
+++ b/shortcuts/mail/signature_compose_test.go
@@ -12,6 +12,7 @@ import (
"testing"
"github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/shortcuts/mail/signature"
)
func TestDownloadSignatureImageRejectsInvalidURLs(t *testing.T) {
@@ -188,6 +189,80 @@ func TestValidateSignatureWithPlainTextTypedError(t *testing.T) {
}
}
+func TestValidateSignatureFlagsRejectsNoSignatureWithExplicitID(t *testing.T) {
+ if err := validateSignatureFlags("sig_123", false); err != nil {
+ t.Fatalf("explicit signature without --no-signature should pass: %v", err)
+ }
+ if err := validateSignatureFlags("", true); err != nil {
+ t.Fatalf("--no-signature without explicit signature should pass: %v", err)
+ }
+
+ err := validateSignatureFlags("sig_123", true)
+ var validationErr *errs.ValidationError
+ if !errors.As(err, &validationErr) {
+ t.Fatalf("expected validation error, got %T (%v)", err, err)
+ }
+ if len(validationErr.Params) != 2 {
+ t.Fatalf("params = %#v, want two conflicting params", validationErr.Params)
+ }
+ if validationErr.Params[0].Name != "--no-signature" || validationErr.Params[1].Name != "--signature-id" {
+ t.Fatalf("unexpected params: %#v", validationErr.Params)
+ }
+}
+
+func TestSelectDefaultSendSignatureID(t *testing.T) {
+ resp := &signature.GetSignaturesResponse{
+ Usages: []signature.SignatureUsage{
+ {EmailAddress: "other@example.com", SendMailSignatureID: "sig_other"},
+ {EmailAddress: "Sender@Example.com", SendMailSignatureID: "sig_sender"},
+ {EmailAddress: "empty@example.com", SendMailSignatureID: "0"},
+ },
+ }
+
+ if got := selectDefaultSendSignatureID(resp, "sender@example.com"); got != "sig_sender" {
+ t.Fatalf("matched sender id = %q, want sig_sender", got)
+ }
+ if got := selectDefaultSendSignatureID(resp, "missing@example.com"); got != "" {
+ t.Fatalf("unmatched sender id = %q, want empty", got)
+ }
+ if got := selectDefaultSendSignatureID(&signature.GetSignaturesResponse{
+ Usages: []signature.SignatureUsage{{SendMailSignatureID: " sig_only "}},
+ }, ""); got != "sig_only" {
+ t.Fatalf("single fallback id = %q, want sig_only", got)
+ }
+ if got := selectDefaultSendSignatureID(&signature.GetSignaturesResponse{
+ Usages: []signature.SignatureUsage{
+ {SendMailSignatureID: "sig_a"},
+ {SendMailSignatureID: "sig_b"},
+ },
+ }, ""); got != "" {
+ t.Fatalf("multiple fallback id = %q, want empty", got)
+ }
+}
+
+func TestInjectSignatureIntoPlainText(t *testing.T) {
+ sig := &signatureResult{RenderedTextContent: " Best,\nAlice "}
+ if got, want := injectSignatureIntoPlainText("Hello\n\n", sig), "Hello\n\nBest,\nAlice"; got != want {
+ t.Fatalf("plain text signature = %q, want %q", got, want)
+ }
+ if got := injectSignatureIntoPlainText("", sig); got != "Best,\nAlice" {
+ t.Fatalf("empty body signature = %q", got)
+ }
+ if got := injectSignatureIntoPlainText("Hello", &signatureResult{}); got != "Hello" {
+ t.Fatalf("empty signature should keep body, got %q", got)
+ }
+}
+
+func TestRenderSignatureTextUsesLocalizedImagePlaceholder(t *testing.T) {
+ html := `Logo

Tail
`
+ if got := renderSignatureText(html, "en_us"); !strings.Contains(got, "[image]") {
+ t.Fatalf("English text signature missing image placeholder: %q", got)
+ }
+ if got := renderSignatureText(html, "zh_cn"); !strings.Contains(got, "[图片]") {
+ t.Fatalf("Chinese text signature missing image placeholder: %q", got)
+ }
+}
+
type signatureRoundTripper func(*http.Request) (*http.Response, error)
func (rt signatureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
diff --git a/skills/lark-mail/references/lark-mail-send.md b/skills/lark-mail/references/lark-mail-send.md
index 500feca41..fc3862b5d 100644
--- a/skills/lark-mail/references/lark-mail-send.md
+++ b/skills/lark-mail/references/lark-mail-send.md
@@ -7,6 +7,7 @@
- 抄送/密送
- 本地文件附件(`--attach`)
- 内嵌图片(`--inline`,CID 可用随机字符串)
+- 默认追加当前发件地址的发信签名,可用 `--no-signature` 跳过
本 skill 对应 shortcut:`lark-cli mail +send`。
@@ -59,6 +60,9 @@ lark-cli mail +send --to alice@example.com --subject '预览图' --body '
test
' --dry-run
```
@@ -75,10 +79,11 @@ lark-cli mail +send --to alice@example.com --subject '测试' --body 'test
` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用。可通过 `accessible_mailboxes` 查询可用邮箱 |
| `--cc ` | 否 | 抄送邮箱,多个用逗号分隔 |
| `--bcc ` | 否 | 密送邮箱,多个用逗号分隔 |
-| `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用 |
+| `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用。若追加签名,签名会以纯文本形式附加,不会把邮件升级为 HTML |
| `--attach ` | 否 | 附件文件路径,多个用逗号分隔。相对路径。当附件导致 EML 总大小超过 25 MB 时,超出部分自动上传为超大附件(HTML 邮件插入下载卡片,纯文本邮件追加下载链接),单个文件上限 3 GB |
| `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `
`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `
` 引用。不可与 `--plain-text` 同时使用 |
-| `--signature-id ` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
+| `--signature-id ` | 否 | 显式签名 ID,优先于默认发信签名。运行 `mail +signature` 查看可用签名。不可与 `--no-signature` 同时使用 |
+| `--no-signature` | 否 | 跳过默认签名查询,不追加任何签名。不可与 `--signature-id` 同时使用 |
| `--priority ` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 |
| `--event-summary ` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请(text/calendar)。需同时设置 `--event-start` 和 `--event-end` |
| `--event-start