Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions shortcuts/mail/mail_lint_writepath_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ func TestMailSend_WritePathLintAutofixesFontInEML(t *testing.T) {
"--to", "alice@example.com",
"--subject", "Send",
"--body", `<font color="red">payload</font>`,
"--no-signature",
"--show-lint-details",
}, f, stdout)
if err != nil {
Expand Down
2 changes: 2 additions & 0 deletions shortcuts/mail/mail_request_receipt_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
14 changes: 9 additions & 5 deletions shortcuts/mail/mail_send.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
{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},
Expand All @@ -57,8 +58,11 @@
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")).
POST(mailboxPath(mailboxID, "drafts")).
api = api.GET(mailboxPath(mailboxID, "profile"))
if !runtime.Bool("no-signature") {
api = api.GET(mailboxPath(mailboxID, "settings", "signatures"))

Check warning on line 63 in shortcuts/mail/mail_send.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_send.go#L61-L63

Added lines #L61 - L63 were not covered by tests
}
api = api.POST(mailboxPath(mailboxID, "drafts")).

Check warning on line 65 in shortcuts/mail/mail_send.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_send.go#L65

Added line #L65 was not covered by tests
Body(map[string]interface{}{
"raw": "<base64url-EML>",
"_preview": map[string]interface{}{
Expand Down Expand Up @@ -98,7 +102,7 @@
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
Expand Down Expand Up @@ -195,7 +199,7 @@
}
}

sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail)
sigResult, err := resolveComposeSignature(ctx, runtime, mailboxID, signatureID, senderEmail, runtime.Bool("no-signature"), !plainText)
if err != nil {
return err
}
Expand Down Expand Up @@ -230,7 +234,7 @@
// `lint_applied[]` / `original_blocked[]` even on the plain-text path.
lintApplied, lintBlocked := emptyLintEnvelopeFields()
if plainText {
composedTextBody = body
composedTextBody = appendPlainTextSignature(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.
Expand Down
3 changes: 3 additions & 0 deletions shortcuts/mail/mail_send_confirm_output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -243,6 +245,7 @@ func TestMailSend_WithCalendarEventEmbedded(t *testing.T) {
"--to", "alice@example.com",
"--subject", "Team Sync",
"--body", "<p>Please join us</p>",
"--no-signature",
"--event-summary", "Team Sync",
"--event-start", "2026-05-10T10:00+08:00",
"--event-end", "2026-05-10T11:00+08:00",
Expand Down
254 changes: 254 additions & 0 deletions shortcuts/mail/mail_send_signature_test.go
Original file line number Diff line number Diff line change
@@ -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": "<p>Default Signature</p>"},
}, []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", "<p>Hello</p>",
}, 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": "<div>Best regards<br><strong>Alice</strong><img src=\"cid:logo\"></div>"},
}, []map[string]interface{}{
{"email_address": mailboxID, "send_mail_signature_id": "sig_plain"},
})

err := runMountedMailShortcut(t, MailSend, []string{
"+send",
"--mailbox", mailboxID,
"--to", "alice@example.com",
"--subject", "hello",
"--body", "Hi",
"--plain-text",
}, f, stdout)
if err != nil {
t.Fatalf("send failed: %v", err)
}

eml := decodeDraftRawEML(t, draftStub)
textBody := decodeSinglePartBase64Body(t, eml)
if !strings.Contains(textBody, "Hi\n\nBest regards") {
t.Fatalf("expected plain-text signature after blank line, body=%q EML:\n%s", textBody, eml)
}
if strings.Contains(textBody, "<div>") || strings.Contains(textBody, "<strong>") || strings.Contains(eml, "lark-mail-signature") {
t.Fatalf("plain-text signature should not include HTML tags or wrapper, body=%q EML:\n%s", textBody, eml)
}
}

func TestMailSendNoSignatureSkipsSignatureQuery(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
mailboxID := "no-sig@example.com"
draftStub := registerMailSendDraftStub(reg, mailboxID)

err := runMountedMailShortcut(t, MailSend, []string{
"+send",
"--mailbox", mailboxID,
"--to", "alice@example.com",
"--subject", "hello",
"--body", "<p>Hello</p>",
"--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": "<p>Default Signature</p>"},
{"id": "sig_explicit", "name": "Explicit", "content": "<p>Explicit Signature</p>"},
}, []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", "<p>Hello</p>",
"--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": "<p>Owner Signature</p>"},
{"id": "sig_alias", "name": "Alias", "content": "<p>Alias Signature</p>"},
}, []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", "<p>Hello</p>",
}, 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: "<p>" + longText + "</p>"}, "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")
}
2 changes: 2 additions & 0 deletions shortcuts/mail/mail_template_shortcut_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1041,6 +1041,7 @@ func TestFetchTemplateAttachmentURLs_FailedReasons(t *testing.T) {
"--to", "alice@example.com",
"--subject", "s",
"--body", "<p>b</p>",
"--no-signature",
"--template-id", "33",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "download URL not returned") {
Expand Down Expand Up @@ -1140,6 +1141,7 @@ func TestMailSend_TemplateIDAppliesInlineAndSmall(t *testing.T) {
"--to", "alice@example.com",
"--subject", "override-subj",
"--body", "<p>user body</p>",
"--no-signature",
"--template-id", "42",
}, f, stdout)
if err != nil {
Expand Down
Loading
Loading