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: `
Hello Portal
`} + 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