From 06827a7522f25a47f8008d9098b3c305b4b60da6 Mon Sep 17 00:00:00 2001
From: zhangheng023
Date: Sun, 7 Jun 2026 10:52:01 +0800
Subject: [PATCH] feat: auto-append default signatures in mail send
Align mail +send with mailbox defaults so compose flows pick the sender
configured send signature unless callers explicitly opt out. Preserve the
plain-text path by rendering signatures as text instead of upgrading MIME.
sprint: S1
---
shortcuts/mail/mail_lint_writepath_test.go | 1 +
.../mail_request_receipt_integration_test.go | 153 +++++++++++-
shortcuts/mail/mail_send.go | 23 +-
.../mail/mail_send_confirm_output_test.go | 3 +
.../mail/mail_shortcut_validation_test.go | 14 ++
shortcuts/mail/mail_template_shortcut_test.go | 2 +
shortcuts/mail/signature_compose.go | 225 +++++++++++++++++-
shortcuts/mail/signature_compose_test.go | 23 +-
skills/lark-mail/references/lark-mail-send.md | 8 +-
tests/cli_e2e/mail/mail_send_workflow_test.go | 63 +++++
10 files changed, 499 insertions(+), 16 deletions(-)
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..69587665e 100644
--- a/shortcuts/mail/mail_request_receipt_integration_test.go
+++ b/shortcuts/mail/mail_request_receipt_integration_test.go
@@ -64,18 +64,22 @@ func stubGetMessageWithFormat(reg *httpmock.Registry, messageID string) {
// JSON body of the drafts.create request; decodeCapturedRawEML extracts the
// base64url-decoded EML from it.
func registerDraftCaptureStubs(reg *httpmock.Registry) *httpmock.Stub {
+ return registerDraftCaptureStubsForMailbox(reg, "me", "draft_001")
+}
+
+func registerDraftCaptureStubsForMailbox(reg *httpmock.Registry, mailboxID, draftID string) *httpmock.Stub {
createStub := &httpmock.Stub{
Method: "POST",
- URL: "/user_mailboxes/me/drafts",
+ URL: "/user_mailboxes/" + mailboxID + "/drafts",
Body: map[string]interface{}{
"code": 0,
- "data": map[string]interface{}{"draft_id": "draft_001"},
+ "data": map[string]interface{}{"draft_id": draftID},
},
}
reg.Register(createStub)
reg.Register(&httpmock.Stub{
Method: "POST",
- URL: "/user_mailboxes/me/drafts/draft_001/send",
+ URL: "/user_mailboxes/" + mailboxID + "/drafts/" + draftID + "/send",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
@@ -87,6 +91,50 @@ func registerDraftCaptureStubs(reg *httpmock.Registry) *httpmock.Stub {
return createStub
}
+func registerDraftCreateCaptureStubForMailbox(reg *httpmock.Registry, mailboxID, draftID string) *httpmock.Stub {
+ createStub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/user_mailboxes/" + mailboxID + "/drafts",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{"draft_id": draftID},
+ },
+ }
+ reg.Register(createStub)
+ return createStub
+}
+
+func stubSignatureSettings(reg *httpmock.Registry, mailboxID string, signatures []map[string]interface{}, usages []map[string]interface{}) *httpmock.Stub {
+ stub := &httpmock.Stub{
+ Method: "GET",
+ URL: "/user_mailboxes/" + mailboxID + "/settings/signatures",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "signatures": signatures,
+ "usages": usages,
+ },
+ },
+ }
+ reg.Register(stub)
+ return stub
+}
+
+func stubSendAsSettings(reg *httpmock.Registry, mailboxID string, addrs []map[string]interface{}) *httpmock.Stub {
+ stub := &httpmock.Stub{
+ Method: "GET",
+ URL: "/user_mailboxes/" + mailboxID + "/settings/send_as",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "sendable_addresses": addrs,
+ },
+ },
+ }
+ reg.Register(stub)
+ return stub
+}
+
// decodeCapturedRawEML extracts and base64url-decodes the "raw" field from
// the captured drafts.create request body. Returns "" when the body is
// unavailable or malformed.
@@ -127,6 +175,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 {
@@ -140,6 +189,104 @@ func TestMailSend_RequestReceiptAddsHeader_Integration(t *testing.T) {
}
}
+func TestMailSend_PlainTextExplicitSignatureStaysText(t *testing.T) {
+ f, stdout, _, reg := mailShortcutTestFactory(t)
+ createStub := registerDraftCreateCaptureStubForMailbox(reg, "sig-plain@example.com", "draft_sig_plain")
+ stubSignatureSettings(reg, "sig-plain@example.com", []map[string]interface{}{
+ {"id": "sig_1", "name": "Best", "signature_type": "USER", "content": "Best regards
"},
+ }, nil)
+ stubSendAsSettings(reg, "sig-plain@example.com", []map[string]interface{}{{
+ "email_address": "sig-plain@example.com",
+ "name": "Sender",
+ }})
+
+ err := runMountedMailShortcut(t, MailSend, []string{
+ "+send",
+ "--mailbox", "sig-plain@example.com",
+ "--to", "alice@example.com",
+ "--subject", "hi",
+ "--body", "hello",
+ "--plain-text",
+ "--signature-id", "sig_1",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("send with plain-text signature failed: %v", err)
+ }
+ raw := decodeCapturedRawEML(t, createStub.CapturedBody)
+ normalized := strings.ReplaceAll(raw, "\r\n", "\n")
+ if !strings.Contains(normalized, "hello\n\nBest regards") {
+ t.Errorf("expected plain-text signature block in EML, got:\n%s", raw)
+ }
+ if strings.Contains(raw, "Best regards
") {
+ t.Errorf("plain-text path should not keep signature HTML, got:\n%s", raw)
+ }
+}
+
+func TestMailSend_AutoDefaultSignatureMatchesAliasAndUsesCache(t *testing.T) {
+ f, stdout, _, reg := mailShortcutTestFactory(t)
+ createStub := registerDraftCreateCaptureStubForMailbox(reg, "owner@example.com", "draft_alias")
+ sigStub := stubSignatureSettings(reg, "owner@example.com", []map[string]interface{}{
+ {"id": "sig_owner", "name": "Owner", "signature_type": "USER", "content": "Owner sign
"},
+ {"id": "sig_alias", "name": "Alias", "signature_type": "USER", "content": "Alias sign
"},
+ }, []map[string]interface{}{
+ {"email_address": "owner@example.com", "send_mail_signature_id": "sig_owner"},
+ {"email_address": "alias@example.com", "send_mail_signature_id": "sig_alias"},
+ })
+ sendAsStub := stubSendAsSettings(reg, "owner@example.com", []map[string]interface{}{
+ {"email_address": "owner@example.com", "name": "Owner"},
+ {"email_address": "alias@example.com", "name": "Alias"},
+ })
+
+ err := runMountedMailShortcut(t, MailSend, []string{
+ "+send",
+ "--mailbox", "owner@example.com",
+ "--from", "alias@example.com",
+ "--to", "alice@example.com",
+ "--subject", "hi",
+ "--body", "hello",
+ "--plain-text",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("send with auto default signature failed: %v", err)
+ }
+ raw := decodeCapturedRawEML(t, createStub.CapturedBody)
+ normalized := strings.ReplaceAll(raw, "\r\n", "\n")
+ if !strings.Contains(normalized, "hello\n\nAlias sign") {
+ t.Errorf("expected alias signature in plain-text EML, got:\n%s", raw)
+ }
+ if strings.Contains(raw, "Owner sign") {
+ t.Errorf("owner signature should not be selected for alias sender, got:\n%s", raw)
+ }
+ if len(sigStub.CapturedBodies) != 1 {
+ t.Fatalf("signature list should be fetched once via process cache, got %d", len(sigStub.CapturedBodies))
+ }
+ if len(sendAsStub.CapturedBodies) != 1 {
+ t.Fatalf("send_as should be fetched once for interpolation, got %d", len(sendAsStub.CapturedBodies))
+ }
+}
+
+func TestMailSend_NoSignatureSkipsSignatureLookups(t *testing.T) {
+ f, stdout, _, reg := mailShortcutTestFactory(t)
+ createStub := registerDraftCreateCaptureStubForMailbox(reg, "nosig@example.com", "draft_no_sig")
+
+ err := runMountedMailShortcut(t, MailSend, []string{
+ "+send",
+ "--mailbox", "nosig@example.com",
+ "--from", "alias@example.com",
+ "--to", "alice@example.com",
+ "--subject", "hi",
+ "--body", "hello",
+ "--no-signature",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("send with --no-signature failed: %v", err)
+ }
+ raw := decodeCapturedRawEML(t, createStub.CapturedBody)
+ if !strings.Contains(raw, "hello") {
+ t.Errorf("expected original body in EML, got:\n%s", raw)
+ }
+}
+
// TestMailSend_RequestReceiptNoSender_FailsValidation covers the
// requireSenderForRequestReceipt error path on +send: --request-receipt set,
// no --from, profile returns no primary email → should fail fast with a
diff --git a/shortcuts/mail/mail_send.go b/shortcuts/mail/mail_send.go
index b536aa827..c3f0bda18 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},
@@ -48,6 +49,7 @@ var MailSend = common.Shortcut{
subject := runtime.Str("subject")
confirmSend := runtime.Bool("confirm-send")
mailboxID := resolveComposeMailboxID(runtime)
+ wantsSignature := !runtime.Bool("no-signature")
desc := "Compose email → save as draft"
if confirmSend {
desc = "Compose email → save as draft → send draft"
@@ -57,7 +59,12 @@ var MailSend = common.Shortcut{
api = api.GET(templateMailboxPath(mailboxID, tid)).
Desc("Fetch template to merge with compose flags (subject/body/to/cc/bcc/attachments).")
}
- api = api.GET(mailboxPath(mailboxID, "profile")).
+ api = api.GET(mailboxPath(mailboxID, "profile"))
+ if wantsSignature {
+ api = api.GET(mailboxPath(mailboxID, "settings", "signatures")).
+ GET(mailboxPath(mailboxID, "settings", "send_as"))
+ }
+ api = api.
POST(mailboxPath(mailboxID, "drafts")).
Body(map[string]interface{}{
"raw": "",
@@ -98,7 +105,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.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
return err
}
// Resolve the body content first (reading --body-file if set) so
@@ -137,7 +144,6 @@ var MailSend = common.Shortcut{
sendTime := runtime.Str("send-time")
senderEmail := resolveComposeSenderEmail(runtime)
- signatureID := runtime.Str("signature-id")
priority, err := parsePriority(runtime.Str("priority"))
if err != nil {
return err
@@ -195,7 +201,11 @@ var MailSend = common.Shortcut{
}
}
- sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail)
+ if err := requireSenderForRequestReceipt(runtime, senderEmail); err != nil {
+ return err
+ }
+
+ sigResult, err := resolveComposeSignature(ctx, runtime, mailboxID, senderEmail)
if err != nil {
return err
}
@@ -206,9 +216,6 @@ var MailSend = common.Shortcut{
if senderEmail != "" {
bld = bld.From("", senderEmail)
}
- if err := requireSenderForRequestReceipt(runtime, senderEmail); err != nil {
- return err
- }
if runtime.Bool("request-receipt") {
bld = bld.DispositionNotificationTo("", senderEmail)
}
@@ -230,7 +237,7 @@ var MailSend = common.Shortcut{
// `lint_applied[]` / `original_blocked[]` even on the plain-text path.
lintApplied, lintBlocked := emptyLintEnvelopeFields()
if plainText {
- composedTextBody = body
+ composedTextBody = appendPlainTextSignature(body, renderPlainTextSignature(sigResult))
bld = bld.TextBody([]byte(composedTextBody))
} else if bodyIsHTML(body) || sigResult != nil {
// If signature is requested on plain-text body, auto-upgrade to HTML.
diff --git a/shortcuts/mail/mail_send_confirm_output_test.go b/shortcuts/mail/mail_send_confirm_output_test.go
index 10f89b030..f3add6679 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)
@@ -243,6 +245,7 @@ func TestMailSend_WithCalendarEventEmbedded(t *testing.T) {
"--to", "alice@example.com",
"--subject", "Team Sync",
"--body", "Please join us
",
+ "--no-signature",
"--event-summary", "Team Sync",
"--event-start", "2026-05-10T10:00+08:00",
"--event-end", "2026-05-10T11:00+08:00",
diff --git a/shortcuts/mail/mail_shortcut_validation_test.go b/shortcuts/mail/mail_shortcut_validation_test.go
index 2eb56c0cd..18b3baf91 100644
--- a/shortcuts/mail/mail_shortcut_validation_test.go
+++ b/shortcuts/mail/mail_shortcut_validation_test.go
@@ -100,6 +100,20 @@ func TestRequiredBodyRejectsWhitespaceBodyFile(t *testing.T) {
}
}
+func TestMailSendRejectsNoSignatureWithSignatureID(t *testing.T) {
+ f, stdout, _, _ := mailShortcutTestFactory(t)
+ err := runMountedMailShortcut(t, MailSend, []string{
+ "+send",
+ "--as", "user",
+ "--to", "alice@example.com",
+ "--subject", "conflict",
+ "--body", "hello",
+ "--no-signature",
+ "--signature-id", "sig_123",
+ }, f, stdout)
+ assertValidationError(t, err, "--no-signature and --signature-id are mutually exclusive")
+}
+
// TC-1: +message --as bot --mailbox me → ErrValidation
func TestMailMessageBotMailboxMeReturnsValidationError(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
diff --git a/shortcuts/mail/mail_template_shortcut_test.go b/shortcuts/mail/mail_template_shortcut_test.go
index a7cfff1f2..670d2c2e4 100644
--- a/shortcuts/mail/mail_template_shortcut_test.go
+++ b/shortcuts/mail/mail_template_shortcut_test.go
@@ -1041,6 +1041,7 @@ func TestFetchTemplateAttachmentURLs_FailedReasons(t *testing.T) {
"--to", "alice@example.com",
"--subject", "s",
"--body", "b
",
+ "--no-signature",
"--template-id", "33",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "download URL not returned") {
@@ -1140,6 +1141,7 @@ func TestMailSend_TemplateIDAppliesInlineAndSmall(t *testing.T) {
"--to", "alice@example.com",
"--subject", "override-subj",
"--body", "user body
",
+ "--no-signature",
"--template-id", "42",
}, f, stdout)
if err != nil {
diff --git a/shortcuts/mail/signature_compose.go b/shortcuts/mail/signature_compose.go
index ac7f13cde..1d3a73a58 100644
--- a/shortcuts/mail/signature_compose.go
+++ b/shortcuts/mail/signature_compose.go
@@ -17,6 +17,7 @@ import (
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
"github.com/larksuite/cli/shortcuts/mail/signature"
+ xhtml "golang.org/x/net/html"
)
// signatureFlag is the common flag definition for --signature-id, shared by all compose shortcuts.
@@ -25,6 +26,12 @@ 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 both explicit and default signature lookup for this compose action.",
+}
+
// signatureResult holds the pre-processed signature data ready for HTML injection.
type signatureResult struct {
ID string
@@ -76,6 +83,49 @@ func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailb
}, nil
}
+// resolveComposeSignature applies the +send signature policy order:
+// --no-signature → explicit --signature-id → auto default signature → no signature.
+func resolveComposeSignature(ctx context.Context, runtime *common.RuntimeContext, mailboxID, senderEmail string) (*signatureResult, error) {
+ if runtime.Bool("no-signature") {
+ return nil, nil
+ }
+ if signatureID := runtime.Str("signature-id"); signatureID != "" {
+ return resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail)
+ }
+ defaultID, err := resolveDefaultSendSignature(runtime, mailboxID, senderEmail)
+ if err != nil {
+ return nil, err
+ }
+ if defaultID == "" {
+ return nil, nil
+ }
+ return resolveSignature(ctx, runtime, mailboxID, defaultID, senderEmail)
+}
+
+// resolveDefaultSendSignature returns the default send signature ID that matches
+// the concrete sender email. Empty / "0" usage IDs mean "no default".
+func resolveDefaultSendSignature(runtime *common.RuntimeContext, mailboxID, senderEmail string) (string, error) {
+ resp, err := signature.ListAll(runtime, mailboxID)
+ if err != nil {
+ return "", err
+ }
+ senderEmail = strings.TrimSpace(senderEmail)
+ if senderEmail == "" {
+ return "", nil
+ }
+ for _, usage := range resp.Usages {
+ if !strings.EqualFold(strings.TrimSpace(usage.EmailAddress), senderEmail) {
+ continue
+ }
+ id := strings.TrimSpace(usage.SendMailSignatureID)
+ if id == "" || id == "0" {
+ return "", nil
+ }
+ return id, nil
+ }
+ return "", nil
+}
+
// 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
@@ -244,7 +294,21 @@ func signatureCIDs(sig *signatureResult) []string {
return cids
}
-// validateSignatureWithPlainText returns an error if both --plain-text and --signature-id are set.
+// validateSignatureFlags rejects the only raw-input conflict in +send's
+// signature policy: explicit opt-out and explicit signature ID at the same time.
+func validateSignatureFlags(noSignature bool, signatureID string) error {
+ if noSignature && 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
+}
+
+// validateSignatureWithPlainText remains for reply/draft siblings that still
+// intentionally reject plain-text + signature-id outside this sprint's scope.
func validateSignatureWithPlainText(plainText bool, signatureID string) error {
if plainText && signatureID != "" {
return mailValidationError("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode").
@@ -255,3 +319,162 @@ func validateSignatureWithPlainText(plainText bool, signatureID string) error {
}
return nil
}
+
+func appendPlainTextSignature(body, signatureText string) string {
+ signatureText = strings.TrimSpace(signatureText)
+ if signatureText == "" {
+ return body
+ }
+ if strings.TrimSpace(body) == "" {
+ return signatureText
+ }
+ return body + "\n\n" + signatureText
+}
+
+func renderPlainTextSignature(sig *signatureResult) string {
+ if sig == nil {
+ return ""
+ }
+ return plainTextSignatureFromHTML(sig.RenderedContent)
+}
+
+func plainTextSignatureFromHTML(raw string) string {
+ doc, err := xhtml.Parse(strings.NewReader(raw))
+ if err != nil {
+ return strings.TrimSpace(raw)
+ }
+ var buf strings.Builder
+ renderPlainTextNode(&buf, doc)
+ return normalizePlainTextOutput(buf.String())
+}
+
+func renderPlainTextNode(buf *strings.Builder, n *xhtml.Node) {
+ if n == nil || isSignatureNonTextTag(n) {
+ return
+ }
+ if n.Type == xhtml.TextNode {
+ appendPlainTextToken(buf, collapseSignatureWhitespace(n.Data))
+ return
+ }
+ if n.Type == xhtml.ElementNode {
+ switch strings.ToLower(n.Data) {
+ case "img":
+ return
+ case "a":
+ text := normalizePlainTextOutput(collectPlainTextNodeText(n.FirstChild))
+ if text == "" {
+ text = strings.TrimSpace(nodeAttr(n, "href"))
+ }
+ appendPlainTextToken(buf, text)
+ return
+ }
+ if isSignatureBlockBoundary(n) {
+ appendPlainTextLineBreak(buf)
+ }
+ }
+ for child := n.FirstChild; child != nil; child = child.NextSibling {
+ renderPlainTextNode(buf, child)
+ }
+ if isSignatureBlockBoundary(n) {
+ appendPlainTextLineBreak(buf)
+ }
+}
+
+func collectPlainTextNodeText(n *xhtml.Node) string {
+ var buf strings.Builder
+ var walk func(*xhtml.Node)
+ walk = func(node *xhtml.Node) {
+ if node == nil || isSignatureNonTextTag(node) {
+ return
+ }
+ if node.Type == xhtml.TextNode {
+ appendPlainTextToken(&buf, collapseSignatureWhitespace(node.Data))
+ return
+ }
+ if node.Type == xhtml.ElementNode && strings.EqualFold(node.Data, "img") {
+ return
+ }
+ for child := node.FirstChild; child != nil; child = child.NextSibling {
+ walk(child)
+ }
+ }
+ walk(n)
+ return buf.String()
+}
+
+func appendPlainTextToken(buf *strings.Builder, token string) {
+ token = strings.TrimSpace(token)
+ if token == "" {
+ return
+ }
+ if buf.Len() > 0 {
+ last := buf.String()[buf.Len()-1]
+ if last != '\n' && last != ' ' {
+ buf.WriteByte(' ')
+ }
+ }
+ buf.WriteString(token)
+}
+
+func appendPlainTextLineBreak(buf *strings.Builder) {
+ if buf.Len() == 0 {
+ return
+ }
+ if buf.String()[buf.Len()-1] != '\n' {
+ buf.WriteByte('\n')
+ }
+}
+
+func normalizePlainTextOutput(raw string) string {
+ lines := strings.Split(raw, "\n")
+ out := make([]string, 0, len(lines))
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line != "" {
+ out = append(out, line)
+ }
+ }
+ return strings.Join(out, "\n")
+}
+
+func collapseSignatureWhitespace(s string) string {
+ return strings.Join(strings.Fields(s), " ")
+}
+
+func isSignatureNonTextTag(n *xhtml.Node) bool {
+ if n == nil || n.Type != xhtml.ElementNode {
+ return false
+ }
+ switch strings.ToLower(n.Data) {
+ case "head", "meta", "script", "noscript", "style", "link", "title":
+ return true
+ default:
+ return false
+ }
+}
+
+func isSignatureBlockBoundary(n *xhtml.Node) bool {
+ if n == nil || n.Type != xhtml.ElementNode {
+ return false
+ }
+ switch strings.ToLower(n.Data) {
+ case "address", "article", "aside", "blockquote", "br", "dd", "div", "dl", "dt",
+ "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6",
+ "header", "hr", "li", "main", "nav", "ol", "p", "pre", "section", "table", "tr", "ul":
+ return true
+ default:
+ return false
+ }
+}
+
+func nodeAttr(n *xhtml.Node, key string) string {
+ if n == nil {
+ return ""
+ }
+ for _, attr := range n.Attr {
+ if strings.EqualFold(attr.Key, key) {
+ return attr.Val
+ }
+ }
+ return ""
+}
diff --git a/shortcuts/mail/signature_compose_test.go b/shortcuts/mail/signature_compose_test.go
index 51bf63f97..5dc867a17 100644
--- a/shortcuts/mail/signature_compose_test.go
+++ b/shortcuts/mail/signature_compose_test.go
@@ -174,8 +174,8 @@ func TestDownloadSignatureImageSuccessUsesFilenameContentType(t *testing.T) {
}
}
-func TestValidateSignatureWithPlainTextTypedError(t *testing.T) {
- err := validateSignatureWithPlainText(true, "sig_123")
+func TestValidateSignatureFlagsTypedError(t *testing.T) {
+ err := validateSignatureFlags(true, "sig_123")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T (%v)", err, err)
@@ -183,11 +183,28 @@ func TestValidateSignatureWithPlainTextTypedError(t *testing.T) {
if len(validationErr.Params) != 2 {
t.Fatalf("params = %#v, want two conflicting params", validationErr.Params)
}
- if validationErr.Params[0].Name != "--plain-text" || validationErr.Params[1].Name != "--signature-id" {
+ if validationErr.Params[0].Name != "--no-signature" || validationErr.Params[1].Name != "--signature-id" {
t.Fatalf("unexpected params: %#v", validationErr.Params)
}
}
+func TestRenderPlainTextSignatureUsesAnchorTextAndHrefFallback(t *testing.T) {
+ sig := &signatureResult{RenderedContent: ``}
+ got := renderPlainTextSignature(sig)
+ if want := "Hello Portal\nhttps://example.com/raw"; got != want {
+ t.Fatalf("renderPlainTextSignature() = %q, want %q", got, want)
+ }
+}
+
+func TestAppendPlainTextSignatureSkipsEmptyRender(t *testing.T) {
+ if got := appendPlainTextSignature("body", " "); got != "body" {
+ t.Fatalf("appendPlainTextSignature() = %q, want body", got)
+ }
+ if got := appendPlainTextSignature("", "sig"); got != "sig" {
+ t.Fatalf("appendPlainTextSignature() empty body = %q, want sig", 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..f0a79b262 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 可用随机字符串)
+- 默认自动追加当前 sender 对应的发送签名(可用 `--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
```
@@ -78,7 +82,8 @@ lark-cli mail +send --to alice@example.com --subject '测试' --body 'test
` | 否 | 附件文件路径,多个用逗号分隔。相对路径。当附件导致 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` 查看可用签名;优先级高于自动默认签名。HTML 正文继续插入 HTML 签名,纯文本正文会追加纯文本签名 |
+| `--no-signature` | 否 | 显式跳过签名追加。优先级高于 `--signature-id` 与自动默认签名;不可与 `--signature-id` 同时使用 |
| `--priority ` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 |
| `--event-summary ` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请(text/calendar)。需同时设置 `--event-start` 和 `--event-end` |
| `--event-start