From e4e1dbb8f9e1211023e9bdb1681f4d14d39944e5 Mon Sep 17 00:00:00 2001
From: oOvalm <126466766+oOvalm@users.noreply.github.com>
Date: Sun, 7 Jun 2026 15:46:18 +0800
Subject: [PATCH 1/5] feat(mail): apply default send signatures
---
shortcuts/mail/mail_lint_writepath_test.go | 1 +
.../mail_request_receipt_integration_test.go | 2 +
shortcuts/mail/mail_send.go | 24 +-
.../mail/mail_send_confirm_output_test.go | 3 +
shortcuts/mail/mail_send_signature.go | 262 ++++++++++
shortcuts/mail/mail_send_signature_test.go | 447 ++++++++++++++++++
shortcuts/mail/mail_template_shortcut_test.go | 2 +
skills/lark-mail/references/lark-mail-send.md | 16 +-
tests/cli_e2e/mail/mail_send_dryrun_test.go | 134 ++++++
tests/cli_e2e/mail/mail_send_workflow_test.go | 1 +
10 files changed, 885 insertions(+), 7 deletions(-)
create mode 100644 shortcuts/mail/mail_send_signature.go
create mode 100644 shortcuts/mail/mail_send_signature_test.go
create mode 100644 tests/cli_e2e/mail/mail_send_dryrun_test.go
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..3fa87e5c7 100644
--- a/shortcuts/mail/mail_send.go
+++ b/shortcuts/mail/mail_send.go
@@ -39,7 +39,8 @@ var MailSend = common.Shortcut{
{Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."},
{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,
+ mailSendSignatureFlag,
+ mailSendNoSignatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
showLintDetailsFlag},
@@ -52,12 +53,21 @@ var MailSend = common.Shortcut{
if confirmSend {
desc = "Compose email → save as draft → send draft"
}
+ noSignature := runtime.Bool("no-signature")
api := common.NewDryRunAPI().Desc(desc)
if tid := runtime.Str("template-id"); tid != "" {
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")).
+ Desc("Resolve mailbox profile and sender address.")
+ if !noSignature {
+ api = api.GET(mailboxPath(mailboxID, "settings", "signatures")).
+ Desc("Resolve explicit signature or default send signature.")
+ api = api.GET(mailboxPath(mailboxID, "settings", "send_as")).
+ Desc("Conditionally resolve sender identity for signature template variables.")
+ }
+ api = api.
POST(mailboxPath(mailboxID, "drafts")).
Body(map[string]interface{}{
"raw": "",
@@ -98,7 +108,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 := validateMailSendSignatureFlags(runtime); err != nil {
return err
}
// Resolve the body content first (reading --body-file if set) so
@@ -135,6 +145,7 @@ var MailSend = common.Shortcut{
inlineFlag := runtime.Str("inline")
confirmSend := runtime.Bool("confirm-send")
sendTime := runtime.Str("send-time")
+ noSignature := runtime.Bool("no-signature")
senderEmail := resolveComposeSenderEmail(runtime)
signatureID := runtime.Str("signature-id")
@@ -195,7 +206,10 @@ var MailSend = common.Shortcut{
}
}
- sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail)
+ sigResult, err := resolveMailSendComposeSignature(ctx, runtime, mailboxID, senderEmail, mailSendSignatureOptions{
+ SignatureID: signatureID,
+ NoSignature: noSignature,
+ })
if err != nil {
return err
}
@@ -230,10 +244,10 @@ var MailSend = common.Shortcut{
// `lint_applied[]` / `original_blocked[]` even on the plain-text path.
lintApplied, lintBlocked := emptyLintEnvelopeFields()
if plainText {
- composedTextBody = body
+ composedTextBody = appendMailSendPlainTextSignature(body, sigResult, resolveLang(runtime))
bld = bld.TextBody([]byte(composedTextBody))
} else if bodyIsHTML(body) || sigResult != nil {
- // If signature is requested on plain-text body, auto-upgrade to HTML.
+ // Non-plain-text sends can use HTML so signatures keep their rich layout.
htmlBody := body
if !bodyIsHTML(body) {
htmlBody = buildBodyDiv(body, false)
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_send_signature.go b/shortcuts/mail/mail_send_signature.go
new file mode 100644
index 000000000..2b1f6d1d3
--- /dev/null
+++ b/shortcuts/mail/mail_send_signature.go
@@ -0,0 +1,262 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package mail
+
+import (
+ "bytes"
+ "context"
+ stdhtml "html"
+ "strings"
+
+ "github.com/larksuite/cli/shortcuts/common"
+ "github.com/larksuite/cli/shortcuts/mail/signature"
+ xhtml "golang.org/x/net/html"
+)
+
+var mailSendSignatureFlag = common.Flag{
+ Name: "signature-id",
+ Desc: "Optional. Signature ID to append. Overrides the default send signature for this email.",
+}
+
+var mailSendNoSignatureFlag = common.Flag{
+ Name: "no-signature",
+ Type: "bool",
+ Desc: "Skip the default send signature for this email.",
+}
+
+type mailSendSignatureOptions struct {
+ SignatureID string
+ NoSignature bool
+}
+
+func validateMailSendSignatureFlags(runtime *common.RuntimeContext) error {
+ if runtime.Bool("no-signature") && strings.TrimSpace(runtime.Str("signature-id")) != "" {
+ return mailValidationError("--signature-id and --no-signature are mutually exclusive").
+ WithParams(
+ mailInvalidParam("--signature-id", "mutually exclusive with --no-signature"),
+ mailInvalidParam("--no-signature", "mutually exclusive with --signature-id"),
+ )
+ }
+ return nil
+}
+
+func resolveMailSendComposeSignature(ctx context.Context, runtime *common.RuntimeContext, mailboxID, senderEmail string, opts mailSendSignatureOptions) (*signatureResult, error) {
+ if opts.NoSignature {
+ return nil, nil
+ }
+
+ signatureID := strings.TrimSpace(opts.SignatureID)
+ if signatureID != "" {
+ return resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail)
+ }
+
+ resp, err := signature.ListAll(runtime, mailboxID)
+ if err != nil {
+ return nil, mailAppendProblemHint(
+ mailDecorateProblemMessage(err, "failed to look up default send signature"),
+ "pass --no-signature to send without a signature",
+ )
+ }
+
+ defaultID := selectMailSendDefaultSignatureID(resp.Usages, senderEmail)
+ if mailSendSignatureIDIsEmpty(defaultID) {
+ return nil, nil
+ }
+ if !mailSendSignatureExists(resp.Signatures, defaultID) {
+ return nil, mailValidationError("default send signature %q was configured for %q but was not returned by settings/signatures", defaultID, mailSendSignatureSenderLabel(senderEmail)).
+ WithHint("run `lark-cli mail +signature` to inspect signatures, or pass --no-signature to send without a signature")
+ }
+ return resolveSignature(ctx, runtime, mailboxID, defaultID, senderEmail)
+}
+
+func selectMailSendDefaultSignatureID(usages []signature.SignatureUsage, senderEmail string) string {
+ senderEmail = strings.TrimSpace(senderEmail)
+ if senderEmail == "" {
+ if len(usages) != 1 {
+ return ""
+ }
+ return strings.TrimSpace(usages[0].SendMailSignatureID)
+ }
+ for _, usage := range usages {
+ if strings.EqualFold(strings.TrimSpace(usage.EmailAddress), senderEmail) {
+ return strings.TrimSpace(usage.SendMailSignatureID)
+ }
+ }
+ return ""
+}
+
+func mailSendSignatureExists(signatures []signature.Signature, signatureID string) bool {
+ for _, sig := range signatures {
+ if strings.TrimSpace(sig.ID) == signatureID {
+ return true
+ }
+ }
+ return false
+}
+
+func mailSendSignatureIDIsEmpty(signatureID string) bool {
+ signatureID = strings.TrimSpace(signatureID)
+ return signatureID == "" || signatureID == "0"
+}
+
+func mailSendSignatureSenderLabel(senderEmail string) string {
+ if strings.TrimSpace(senderEmail) == "" {
+ return "the resolved sender"
+ }
+ return senderEmail
+}
+
+func appendMailSendPlainTextSignature(body string, sig *signatureResult, lang string) string {
+ if sig == nil {
+ return body
+ }
+ signatureText := renderMailSendSignaturePlainText(sig.RenderedContent, lang)
+ if strings.TrimSpace(signatureText) == "" {
+ return body
+ }
+ body = strings.TrimRight(body, "\r\n")
+ if strings.TrimSpace(body) == "" {
+ return signatureText
+ }
+ return body + "\n\n" + signatureText
+}
+
+func renderMailSendSignaturePlainText(content, lang string) string {
+ content = strings.TrimSpace(content)
+ if content == "" {
+ return ""
+ }
+ doc, err := xhtml.Parse(strings.NewReader(content))
+ if err != nil {
+ return strings.TrimSpace(stdhtml.UnescapeString(content))
+ }
+ var buf bytes.Buffer
+ renderMailSendSignatureText(&buf, doc, mailSendSignatureImagePlaceholder(lang))
+ return compactMailSendSignaturePlainText(buf.String())
+}
+
+func renderMailSendSignatureText(buf *bytes.Buffer, root *xhtml.Node, imagePlaceholder string) {
+ if root == nil {
+ return
+ }
+
+ type pendingEntry struct {
+ node *xhtml.Node
+ child *xhtml.Node
+ }
+ stack := []pendingEntry{{node: root, child: root.FirstChild}}
+
+ for len(stack) > 0 {
+ top := &stack[len(stack)-1]
+ if top.child == nil {
+ if mailSendSignatureBlockNode(top.node) {
+ writeMailSendSignatureBreak(buf)
+ }
+ stack = stack[:len(stack)-1]
+ continue
+ }
+
+ node := top.child
+ top.child = node.NextSibling
+
+ if node.Type == xhtml.ElementNode {
+ tag := strings.ToLower(node.Data)
+ if mailSendSignatureSkipsText(tag) {
+ continue
+ }
+ switch tag {
+ case "br":
+ writeMailSendSignatureBreak(buf)
+ continue
+ case "img":
+ appendMailSendSignatureText(buf, imagePlaceholder)
+ continue
+ }
+ if mailSendSignatureBlockTag(tag) {
+ writeMailSendSignatureBreak(buf)
+ }
+ }
+ if node.Type == xhtml.TextNode {
+ appendMailSendSignatureText(buf, node.Data)
+ }
+ if node.FirstChild != nil {
+ stack = append(stack, pendingEntry{node: node, child: node.FirstChild})
+ }
+ }
+}
+
+func appendMailSendSignatureText(buf *bytes.Buffer, raw string) {
+ parts := strings.Fields(stdhtml.UnescapeString(raw))
+ if len(parts) == 0 {
+ return
+ }
+ text := strings.Join(parts, " ")
+ if buf.Len() > 0 {
+ last := buf.Bytes()[buf.Len()-1]
+ if last != '\n' && last != ' ' {
+ buf.WriteByte(' ')
+ }
+ }
+ buf.WriteString(text)
+}
+
+func writeMailSendSignatureBreak(buf *bytes.Buffer) {
+ for buf.Len() > 0 {
+ last := buf.Bytes()[buf.Len()-1]
+ if last != ' ' && last != '\t' && last != '\r' {
+ break
+ }
+ buf.Truncate(buf.Len() - 1)
+ }
+ if buf.Len() == 0 {
+ return
+ }
+ if buf.Bytes()[buf.Len()-1] != '\n' {
+ buf.WriteByte('\n')
+ }
+}
+
+func compactMailSendSignaturePlainText(text string) string {
+ text = strings.ReplaceAll(text, "\r\n", "\n")
+ text = strings.ReplaceAll(text, "\r", "\n")
+ lines := strings.Split(text, "\n")
+ out := make([]string, 0, len(lines))
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ out = append(out, line)
+ }
+ return strings.TrimSpace(strings.Join(out, "\n"))
+}
+
+func mailSendSignatureImagePlaceholder(lang string) string {
+ if strings.HasPrefix(strings.ToLower(lang), "zh") {
+ return "[图片]"
+ }
+ return "[image]"
+}
+
+func mailSendSignatureSkipsText(tag string) bool {
+ switch tag {
+ case "head", "script", "style", "title", "meta", "link", "noscript":
+ return true
+ default:
+ return false
+ }
+}
+
+func mailSendSignatureBlockNode(node *xhtml.Node) bool {
+ return node != nil && node.Type == xhtml.ElementNode && mailSendSignatureBlockTag(strings.ToLower(node.Data))
+}
+
+func mailSendSignatureBlockTag(tag string) bool {
+ switch tag {
+ case "address", "article", "aside", "blockquote", "dd", "div", "dl", "dt", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr", "li", "main", "nav", "ol", "p", "pre", "section", "table", "tbody", "tfoot", "thead", "tr", "ul":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/shortcuts/mail/mail_send_signature_test.go b/shortcuts/mail/mail_send_signature_test.go
new file mode 100644
index 000000000..45e85e904
--- /dev/null
+++ b/shortcuts/mail/mail_send_signature_test.go
@@ -0,0 +1,447 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package mail
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/httpmock"
+ "github.com/larksuite/cli/shortcuts/common"
+ "github.com/larksuite/cli/shortcuts/mail/signature"
+)
+
+func TestSelectMailSendDefaultSignatureID(t *testing.T) {
+ usages := []signature.SignatureUsage{
+ {EmailAddress: "owner@example.com", SendMailSignatureID: "sig_owner"},
+ {EmailAddress: "alias@example.com", SendMailSignatureID: " sig_alias "},
+ }
+
+ if got := selectMailSendDefaultSignatureID(usages, "ALIAS@example.com"); got != "sig_alias" {
+ t.Fatalf("alias match = %q, want sig_alias", got)
+ }
+ if got := selectMailSendDefaultSignatureID(usages, "nobody@example.com"); got != "" {
+ t.Fatalf("no match = %q, want empty", got)
+ }
+ if got := selectMailSendDefaultSignatureID([]signature.SignatureUsage{{SendMailSignatureID: "sig_single"}}, ""); got != "sig_single" {
+ t.Fatalf("single fallback = %q, want sig_single", got)
+ }
+ if got := selectMailSendDefaultSignatureID(usages, ""); got != "" {
+ t.Fatalf("multiple fallback = %q, want empty", got)
+ }
+}
+
+func TestAppendMailSendPlainTextSignatureRendersHTML(t *testing.T) {
+ got := appendMailSendPlainTextSignature("Hello\n", &signatureResult{
+ ID: "sig_plain",
+ RenderedContent: `Kind
Alice
`,
+ }, "en_us")
+
+ want := "Hello\n\nKind\nAlice [image]"
+ if got != want {
+ t.Fatalf("plain-text signature body = %q, want %q", got, want)
+ }
+}
+
+func TestValidateMailSendSignatureFlagsRejectsNoSignatureConflict(t *testing.T) {
+ f, stdout, _, _ := mailShortcutTestFactory(t)
+ err := runMountedMailShortcut(t, MailSend, []string{
+ "+send",
+ "--to", "alice@example.com",
+ "--subject", "hello",
+ "--body", "world",
+ "--signature-id", "sig_123",
+ "--no-signature",
+ }, f, stdout)
+
+ 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 signature/no-signature conflict", validationErr.Params)
+ }
+}
+
+func TestMailSendDefaultSignatureUsesAliasSender(t *testing.T) {
+ f, stdout, _, reg := mailShortcutTestFactory(t)
+ createStub := registerMailSendSignatureScenario(t, reg, mailSendSignatureScenario{
+ MailboxID: "sig_default_alias_box",
+ DefaultEmail: "owner@example.com",
+ DefaultSigID: "sig_owner",
+ SignatureHTML: `Owner Signature
`,
+ AliasEmail: "alias@example.com",
+ AliasSigID: "sig_alias",
+ AliasSignature: `Alias Signature
`,
+ })
+
+ err := runMountedMailShortcut(t, MailSend, []string{
+ "+send",
+ "--mailbox", "sig_default_alias_box",
+ "--from", "alias@example.com",
+ "--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, "Alias Signature") {
+ t.Fatalf("expected alias signature in EML:\n%s", raw)
+ }
+ if strings.Contains(raw, "Owner Signature") {
+ t.Fatalf("default owner signature should not be used for alias sender:\n%s", raw)
+ }
+}
+
+func TestMailSendExplicitSignatureOverridesDefault(t *testing.T) {
+ f, stdout, _, reg := mailShortcutTestFactory(t)
+ createStub := registerMailSendSignatureScenario(t, reg, mailSendSignatureScenario{
+ MailboxID: "sig_explicit_box",
+ DefaultEmail: "owner@example.com",
+ DefaultSigID: "sig_default",
+ SignatureHTML: `Default Signature
`,
+ ExtraSignatures: []signature.Signature{
+ {ID: "sig_explicit", Name: "Explicit", SignatureType: signature.SignatureTypeUser, Content: `Explicit Signature
`},
+ },
+ })
+
+ err := runMountedMailShortcut(t, MailSend, []string{
+ "+send",
+ "--mailbox", "sig_explicit_box",
+ "--from", "owner@example.com",
+ "--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("expected explicit signature in EML:\n%s", raw)
+ }
+ if strings.Contains(raw, "Default Signature") {
+ t.Fatalf("explicit signature should override default signature:\n%s", raw)
+ }
+}
+
+func TestMailSendNoSignatureSkipsSignatureAPIs(t *testing.T) {
+ f, stdout, _, reg := mailShortcutTestFactory(t)
+ createStub := registerMailSendDraftCreate(reg, "sig_no_signature_box")
+
+ err := runMountedMailShortcut(t, MailSend, []string{
+ "+send",
+ "--mailbox", "sig_no_signature_box",
+ "--from", "owner@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") {
+ t.Fatalf("signature block should be absent when --no-signature is set:\n%s", raw)
+ }
+}
+
+func TestMailSendPlainTextExplicitSignatureAppendsText(t *testing.T) {
+ f, stdout, _, reg := mailShortcutTestFactory(t)
+ createStub := registerMailSendSignatureScenario(t, reg, mailSendSignatureScenario{
+ MailboxID: "sig_plain_text_box",
+ DefaultEmail: "owner@example.com",
+ DefaultSigID: "sig_default",
+ SignatureHTML: `Default
`,
+ ExtraSignatures: []signature.Signature{
+ {ID: "sig_text", Name: "Text", SignatureType: signature.SignatureTypeUser, Content: `Kind
Alice
`},
+ },
+ })
+
+ err := runMountedMailShortcut(t, MailSend, []string{
+ "+send",
+ "--mailbox", "sig_plain_text_box",
+ "--from", "owner@example.com",
+ "--to", "bob@example.com",
+ "--subject", "hello",
+ "--body", "Hello",
+ "--plain-text",
+ "--signature-id", "sig_text",
+ }, 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("expected text/plain EML:\n%s", raw)
+ }
+ if strings.Contains(raw, "Content-Type: text/html") {
+ t.Fatalf("plain-text signature must not upgrade to HTML:\n%s", raw)
+ }
+ if !strings.Contains(raw, "Hello\n\nKind\nAlice") {
+ t.Fatalf("expected plain-text signature appended after body:\n%s", raw)
+ }
+}
+
+func TestMailSendDefaultSignatureLookupFailureHintsNoSignature(t *testing.T) {
+ f, stdout, _, reg := mailShortcutTestFactory(t)
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/user_mailboxes/sig_lookup_failure_box/settings/signatures",
+ Body: map[string]interface{}{
+ "code": 999,
+ "msg": "signature service unavailable",
+ },
+ })
+
+ err := runMountedMailShortcut(t, MailSend, []string{
+ "+send",
+ "--mailbox", "sig_lookup_failure_box",
+ "--from", "owner@example.com",
+ "--to", "bob@example.com",
+ "--subject", "hello",
+ "--body", "Hello
",
+ }, f, stdout)
+ if err == nil {
+ t.Fatal("expected lookup failure")
+ }
+ p, ok := errs.ProblemOf(err)
+ if !ok {
+ t.Fatalf("expected typed problem, got %T (%v)", err, err)
+ }
+ if !strings.Contains(p.Message, "failed to look up default send signature") {
+ t.Fatalf("message = %q, want default lookup prefix", p.Message)
+ }
+ if !strings.Contains(p.Hint, "--no-signature") {
+ t.Fatalf("hint = %q, want --no-signature", p.Hint)
+ }
+}
+
+func TestMailSendDefaultSignatureMissingTargetHintsUser(t *testing.T) {
+ f, stdout, _, reg := mailShortcutTestFactory(t)
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/user_mailboxes/sig_missing_target_box/settings/signatures",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "signatures": []interface{}{},
+ "usages": []interface{}{
+ map[string]interface{}{
+ "email_address": "owner@example.com",
+ "send_mail_signature_id": "sig_missing",
+ "reply_signature_id": "0",
+ },
+ },
+ },
+ },
+ })
+
+ err := runMountedMailShortcut(t, MailSend, []string{
+ "+send",
+ "--mailbox", "sig_missing_target_box",
+ "--from", "owner@example.com",
+ "--to", "bob@example.com",
+ "--subject", "hello",
+ "--body", "Hello
",
+ }, f, stdout)
+ if err == nil {
+ t.Fatal("expected missing signature validation error")
+ }
+ p, ok := errs.ProblemOf(err)
+ if !ok {
+ t.Fatalf("expected typed problem, got %T (%v)", err, err)
+ }
+ if !strings.Contains(p.Message, "default send signature") {
+ t.Fatalf("message = %q, want default send signature", p.Message)
+ }
+ if !strings.Contains(p.Hint, "mail +signature") || !strings.Contains(p.Hint, "--no-signature") {
+ t.Fatalf("hint = %q, want mail +signature and --no-signature", p.Hint)
+ }
+}
+
+func TestMailSendSignatureSendAsFailureDegrades(t *testing.T) {
+ f, stdout, _, reg := mailShortcutTestFactory(t)
+ createStub := registerMailSendSignatureScenario(t, reg, mailSendSignatureScenario{
+ MailboxID: "sig_send_as_degrade_box",
+ DefaultEmail: "owner@example.com",
+ DefaultSigID: "sig_default",
+ SignatureHTML: `Default Signature
`,
+ SkipSendAsStub: true,
+ })
+
+ err := runMountedMailShortcut(t, MailSend, []string{
+ "+send",
+ "--mailbox", "sig_send_as_degrade_box",
+ "--from", "owner@example.com",
+ "--to", "bob@example.com",
+ "--subject", "hello",
+ "--body", "Hello
",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("send should degrade when send_as lookup fails: %v", err)
+ }
+ raw := decodeCapturedRawEML(t, createStub.CapturedBody)
+ if !strings.Contains(raw, "Default Signature") {
+ t.Fatalf("signature should still be appended when send_as fails:\n%s", raw)
+ }
+}
+
+func TestNonSendComposeStillRejectsPlainTextSignatureID(t *testing.T) {
+ cases := []struct {
+ name string
+ shortcut common.Shortcut
+ args []string
+ }{
+ {
+ name: "draft-create",
+ shortcut: MailDraftCreate,
+ args: []string{
+ "+draft-create", "--to", "alice@example.com", "--subject", "s", "--body", "body", "--plain-text", "--signature-id", "sig_123",
+ },
+ },
+ {
+ name: "reply",
+ shortcut: MailReply,
+ args: []string{
+ "+reply", "--message-id", "msg_001", "--body", "body", "--plain-text", "--signature-id", "sig_123",
+ },
+ },
+ {
+ name: "reply-all",
+ shortcut: MailReplyAll,
+ args: []string{
+ "+reply-all", "--message-id", "msg_001", "--body", "body", "--plain-text", "--signature-id", "sig_123",
+ },
+ },
+ {
+ name: "forward",
+ shortcut: MailForward,
+ args: []string{
+ "+forward", "--message-id", "msg_001", "--to", "alice@example.com", "--body", "body", "--plain-text", "--signature-id", "sig_123",
+ },
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ f, stdout, _, _ := mailShortcutTestFactory(t)
+ err := runMountedMailShortcut(t, tc.shortcut, tc.args, f, stdout)
+ assertValidationError(t, err, "--plain-text and --signature-id are mutually exclusive")
+ })
+ }
+}
+
+type mailSendSignatureScenario struct {
+ MailboxID string
+ DefaultEmail string
+ DefaultSigID string
+ SignatureHTML string
+ AliasEmail string
+ AliasSigID string
+ AliasSignature string
+ ExtraSignatures []signature.Signature
+ SkipSendAsStub bool
+}
+
+func registerMailSendSignatureScenario(t *testing.T, reg *httpmock.Registry, scenario mailSendSignatureScenario) *httpmock.Stub {
+ t.Helper()
+ signatures := []interface{}{
+ map[string]interface{}{
+ "id": scenario.DefaultSigID,
+ "name": "Default",
+ "signature_type": string(signature.SignatureTypeUser),
+ "signature_device": string(signature.DevicePC),
+ "content": scenario.SignatureHTML,
+ },
+ }
+ usages := []interface{}{
+ map[string]interface{}{
+ "email_address": scenario.DefaultEmail,
+ "send_mail_signature_id": scenario.DefaultSigID,
+ "reply_signature_id": "0",
+ },
+ }
+ if scenario.AliasEmail != "" {
+ signatures = append(signatures, map[string]interface{}{
+ "id": scenario.AliasSigID,
+ "name": "Alias",
+ "signature_type": string(signature.SignatureTypeUser),
+ "signature_device": string(signature.DevicePC),
+ "content": scenario.AliasSignature,
+ })
+ usages = append(usages, map[string]interface{}{
+ "email_address": scenario.AliasEmail,
+ "send_mail_signature_id": scenario.AliasSigID,
+ "reply_signature_id": "0",
+ })
+ }
+ for _, sig := range scenario.ExtraSignatures {
+ signatures = append(signatures, map[string]interface{}{
+ "id": sig.ID,
+ "name": sig.Name,
+ "signature_type": string(sig.SignatureType),
+ "signature_device": string(signature.DevicePC),
+ "content": sig.Content,
+ })
+ }
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/user_mailboxes/" + scenario.MailboxID + "/settings/signatures",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "signatures": signatures,
+ "usages": usages,
+ },
+ },
+ })
+ if !scenario.SkipSendAsStub {
+ registerMailSendSignatureSendAs(reg, scenario.MailboxID, scenario.DefaultEmail, scenario.AliasEmail)
+ }
+ return registerMailSendDraftCreate(reg, scenario.MailboxID)
+}
+
+func registerMailSendSignatureSendAs(reg *httpmock.Registry, mailboxID, defaultEmail, aliasEmail string) {
+ addresses := []interface{}{
+ map[string]interface{}{"name": "Owner", "email_address": defaultEmail},
+ }
+ if aliasEmail != "" {
+ addresses = append(addresses, map[string]interface{}{"name": "Alias", "email_address": aliasEmail})
+ }
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/user_mailboxes/" + mailboxID + "/settings/send_as",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "sendable_addresses": addresses,
+ },
+ },
+ })
+}
+
+func registerMailSendDraftCreate(reg *httpmock.Registry, mailboxID string) *httpmock.Stub {
+ stub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/user_mailboxes/" + mailboxID + "/drafts",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{
+ "draft_id": "draft_" + mailboxID,
+ },
+ },
+ }
+ reg.Register(stub)
+ return stub
+}
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/skills/lark-mail/references/lark-mail-send.md b/skills/lark-mail/references/lark-mail-send.md
index 500feca41..c32933ee0 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 可用随机字符串)
+- 默认追加当前发件人的发送签名;可用 `--signature-id` 指定签名,或用 `--no-signature` 跳过签名
本 skill 对应 shortcut:`lark-cli mail +send`。
@@ -59,6 +60,13 @@ lark-cli mail +send --to alice@example.com --subject '预览图' --body '
test
' --dry-run
```
@@ -75,10 +83,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` 同时使用。若使用默认签名或 `--signature-id`,签名会转为纯文本追加到正文末尾,不会升级为 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` | 否 | 不附加默认签名,也不查询签名和 send_as 设置。不可与 `--signature-id` 同时使用 |
| `--priority ` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 |
| `--event-summary ` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请(text/calendar)。需同时设置 `--event-start` 和 `--event-end` |
| `--event-start