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 := `
LogoTail
` + 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