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
153 changes: 150 additions & 3 deletions shortcuts/mail/mail_request_receipt_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}{
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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": "<div>Best regards</div>"},
}, 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, "<div>Best regards</div>") {
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": "<div>Owner sign</div>"},
{"id": "sig_alias", "name": "Alias", "signature_type": "USER", "content": "<div>Alias sign</div>"},
}, []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
Expand Down
23 changes: 15 additions & 8 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 @@ -48,6 +49,7 @@
subject := runtime.Str("subject")
confirmSend := runtime.Bool("confirm-send")
mailboxID := resolveComposeMailboxID(runtime)
wantsSignature := !runtime.Bool("no-signature")

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

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_send.go#L52

Added line #L52 was not covered by tests
desc := "Compose email → save as draft"
if confirmSend {
desc = "Compose email → save as draft → send draft"
Expand All @@ -57,7 +59,12 @@
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"))

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#L62-L65

Added lines #L62 - L65 were not covered by tests
}
api = api.

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

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_send.go#L67

Added line #L67 was not covered by tests
POST(mailboxPath(mailboxID, "drafts")).
Body(map[string]interface{}{
"raw": "<base64url-EML>",
Expand Down Expand Up @@ -98,7 +105,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 @@ -137,7 +144,6 @@
sendTime := runtime.Str("send-time")

senderEmail := resolveComposeSenderEmail(runtime)
signatureID := runtime.Str("signature-id")
priority, err := parsePriority(runtime.Str("priority"))
if err != nil {
return err
Expand Down Expand Up @@ -195,7 +201,11 @@
}
}

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
}
Expand All @@ -206,9 +216,6 @@
if senderEmail != "" {
bld = bld.From("", senderEmail)
}
if err := requireSenderForRequestReceipt(runtime, senderEmail); err != nil {
return err
}
if runtime.Bool("request-receipt") {
bld = bld.DispositionNotificationTo("", senderEmail)
}
Expand All @@ -230,7 +237,7 @@
// `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.
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
14 changes: 14 additions & 0 deletions shortcuts/mail/mail_shortcut_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
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