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_test.go b/shortcuts/mail/mail_send_signature_test.go new file mode 100644 index 0000000000..ae50e809d3 --- /dev/null +++ b/shortcuts/mail/mail_send_signature_test.go @@ -0,0 +1,254 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "encoding/base64" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestMailSendDefaultSignatureHTML(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + mailboxID := "sig-html@example.com" + draftStub := registerMailSendDraftStub(reg, mailboxID) + registerSignaturesStub(reg, mailboxID, []map[string]interface{}{ + {"id": "sig_default", "name": "Default", "content": "Default Signature
"}, + }, []map[string]interface{}{ + {"email_address": mailboxID, "send_mail_signature_id": "sig_default"}, + }) + + err := runMountedMailShortcut(t, MailSend, []string{ + "+send", + "--mailbox", mailboxID, + "--to", "alice@example.com", + "--subject", "hello", + "--body", "Hello
", + }, f, stdout) + if err != nil { + t.Fatalf("send failed: %v", err) + } + + eml := decodeDraftRawEML(t, draftStub) + if !strings.Contains(eml, "lark-mail-signature") { + t.Fatalf("expected signature wrapper in EML:\n%s", eml) + } + if !strings.Contains(eml, "Default Signature") { + t.Fatalf("expected default signature content in EML:\n%s", eml) + } +} + +func TestMailSendDefaultSignaturePlainText(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + mailboxID := "sig-plain@example.com" + draftStub := registerMailSendDraftStub(reg, mailboxID) + registerSignaturesStub(reg, mailboxID, []map[string]interface{}{ + {"id": "sig_plain", "name": "Plain", "content": "Hello
", + "--no-signature", + }, f, stdout) + if err != nil { + t.Fatalf("send failed: %v", err) + } + + eml := decodeDraftRawEML(t, draftStub) + if strings.Contains(eml, "lark-mail-signature") { + t.Fatalf("did not expect signature wrapper in EML:\n%s", eml) + } +} + +func TestMailSendExplicitSignatureOverridesDefault(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + mailboxID := "sig-explicit@example.com" + draftStub := registerMailSendDraftStub(reg, mailboxID) + registerSignaturesStub(reg, mailboxID, []map[string]interface{}{ + {"id": "sig_default", "name": "Default", "content": "Default Signature
"}, + {"id": "sig_explicit", "name": "Explicit", "content": "Explicit Signature
"}, + }, []map[string]interface{}{ + {"email_address": mailboxID, "send_mail_signature_id": "sig_default"}, + }) + + err := runMountedMailShortcut(t, MailSend, []string{ + "+send", + "--mailbox", mailboxID, + "--to", "alice@example.com", + "--subject", "hello", + "--body", "Hello
", + "--signature-id", "sig_explicit", + }, f, stdout) + if err != nil { + t.Fatalf("send failed: %v", err) + } + + eml := decodeDraftRawEML(t, draftStub) + if !strings.Contains(eml, "Explicit Signature") { + t.Fatalf("expected explicit signature in EML:\n%s", eml) + } + if strings.Contains(eml, "Default Signature") { + t.Fatalf("explicit signature should override default signature:\n%s", eml) + } +} + +func TestMailSendNoSignatureAndSignatureIDMutuallyExclusive(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailSend, []string{ + "+send", + "--to", "alice@example.com", + "--subject", "hello", + "--body", "Hello", + "--no-signature", + "--signature-id", "sig_123", + }, f, stdout) + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected validation error, got %T (%v)", err, err) + } + if !strings.Contains(err.Error(), "--no-signature and --signature-id are mutually exclusive") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestMailSendDefaultSignatureMatchesFromUsage(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + mailboxID := "owner@example.com" + draftStub := registerMailSendDraftStub(reg, mailboxID) + registerSignaturesStub(reg, mailboxID, []map[string]interface{}{ + {"id": "sig_owner", "name": "Owner", "content": "Owner Signature
"}, + {"id": "sig_alias", "name": "Alias", "content": "Alias Signature
"}, + }, []map[string]interface{}{ + {"email_address": mailboxID, "send_mail_signature_id": "sig_owner"}, + {"email_address": "alias@example.com", "send_mail_signature_id": "sig_alias"}, + }) + + err := runMountedMailShortcut(t, MailSend, []string{ + "+send", + "--mailbox", mailboxID, + "--from", "alias@example.com", + "--to", "alice@example.com", + "--subject", "hello", + "--body", "Hello
", + }, f, stdout) + if err != nil { + t.Fatalf("send failed: %v", err) + } + + eml := decodeDraftRawEML(t, draftStub) + if !strings.Contains(eml, "Alias Signature") { + t.Fatalf("expected alias signature in EML:\n%s", eml) + } + if strings.Contains(eml, "Owner Signature") { + t.Fatalf("expected --from usage to win over fallback default:\n%s", eml) + } +} + +func TestAppendPlainTextSignatureDoesNotTruncate(t *testing.T) { + longText := strings.Repeat("x", 240) + got := appendPlainTextSignature("Hi", &signatureResult{RenderedContent: "" + longText + "
"}, "en_us") + if !strings.Contains(got, longText) { + t.Fatalf("plain-text signature was truncated: %q", got) + } +} + +func registerSignaturesStub(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 registerMailSendDraftStub(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_001", + }, + }, + } + reg.Register(stub) + return stub +} + +func decodeDraftRawEML(t *testing.T, stub *httpmock.Stub) string { + t.Helper() + var reqBody map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &reqBody); err != nil { + t.Fatalf("unmarshal captured draft body: %v", err) + } + raw, _ := reqBody["raw"].(string) + decoded, err := base64.URLEncoding.DecodeString(raw) + if err != nil { + t.Fatalf("base64url decode raw EML: %v", err) + } + return string(decoded) +} + +func decodeSinglePartBase64Body(t *testing.T, eml string) string { + t.Helper() + parts := strings.SplitN(eml, "\r\n\r\n", 2) + if len(parts) != 2 { + parts = strings.SplitN(eml, "\n\n", 2) + } + if len(parts) != 2 { + t.Fatalf("EML missing body separator:\n%s", eml) + } + encoded := strings.TrimSpace(parts[1]) + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("decode base64 body: %v; body=%q", err, encoded) + } + return strings.ReplaceAll(string(decoded), "\r\n", "\n") +} diff --git a/shortcuts/mail/mail_template_shortcut_test.go b/shortcuts/mail/mail_template_shortcut_test.go index a7cfff1f2b..670d2c2e45 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 ac7f13cde2..1edc597826 100644 --- a/shortcuts/mail/signature_compose.go +++ b/shortcuts/mail/signature_compose.go @@ -5,10 +5,12 @@ package mail import ( "context" + "html" "io" "net/http" "net/url" "path/filepath" + "regexp" "strings" "time" @@ -22,7 +24,13 @@ import ( // signatureFlag is the common flag definition for --signature-id, shared by all compose shortcuts. var signatureFlag = common.Flag{ Name: "signature-id", - Desc: "Optional. Signature ID to append after body content. Run `mail +signature` to list available signatures.", + Desc: "Optional. Signature ID to append after body content. For mail +send, overrides the default send signature. Run `mail +signature` to list available signatures.", +} + +var noSignatureFlag = common.Flag{ + Name: "no-signature", + Type: "bool", + Desc: "Skip appending the default mail signature. Mutually exclusive with --signature-id.", } // signatureResult holds the pre-processed signature data ready for HTML injection. @@ -36,6 +44,10 @@ type signatureResult struct { // 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 resolveSignatureForCompose(ctx, runtime, mailboxID, signatureID, fromEmail, true) +} + +func resolveSignatureForCompose(ctx context.Context, runtime *common.RuntimeContext, mailboxID, signatureID, fromEmail string, includeImages bool) (*signatureResult, error) { if signatureID == "" { return nil, nil } @@ -53,20 +65,22 @@ func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailb // 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{ @@ -76,6 +90,57 @@ func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailb }, nil } +func resolveComposeSignature(ctx context.Context, runtime *common.RuntimeContext, mailboxID, explicitID, fromEmail string, noSignature bool, includeImages bool) (*signatureResult, error) { + if noSignature { + return nil, nil + } + if explicitID != "" { + return resolveSignatureForCompose(ctx, runtime, mailboxID, explicitID, fromEmail, includeImages) + } + + resp, err := signature.ListAll(runtime, mailboxID) + if err != nil { + return nil, err + } + sigID := resolveDefaultSendSignatureID(resp, fromEmail) + if sigID == "" { + return nil, nil + } + return resolveSignatureForCompose(ctx, runtime, mailboxID, sigID, fromEmail, includeImages) +} + +func resolveDefaultSendSignatureID(resp *signature.GetSignaturesResponse, fromEmail string) string { + if resp == nil { + return "" + } + for _, usage := range resp.Usages { + if !validSignatureUsageID(resp, usage.SendMailSignatureID) { + continue + } + if fromEmail != "" && strings.EqualFold(usage.EmailAddress, fromEmail) { + return usage.SendMailSignatureID + } + } + for _, usage := range resp.Usages { + if validSignatureUsageID(resp, usage.SendMailSignatureID) { + return usage.SendMailSignatureID + } + } + return "" +} + +func validSignatureUsageID(resp *signature.GetSignaturesResponse, sigID string) bool { + if sigID == "" || sigID == "0" || resp == nil { + return false + } + for _, sig := range resp.Signatures { + if sig.ID == sigID { + return true + } + } + return false +} + // 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,6 +309,59 @@ func signatureCIDs(sig *signatureResult) []string { return cids } +var ( + plainTextImageTagRe = regexp.MustCompile(`(?is)本周进展:
收到,谢谢。
' --no-signature + # ⚠️ 仅在内容极简时使用纯文本 lark-cli mail +reply --message-idtest
' --dry-run ``` @@ -78,7 +82,8 @@ lark-cli mail +send --to alice@example.com --subject '测试' --body 'test
` | 否 | 附件文件路径,多个用逗号分隔。相对路径。当附件导致 EML 总大小超过 25 MB 时,超出部分自动上传为超大附件(HTML 邮件插入下载卡片,纯文本邮件追加下载链接),单个文件上限 3 GB | | `--inline