mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:55:53 +08:00
End-to-end RFC 3798 Message Disposition Notification support, covering
both sides of the receipt flow — requesting a receipt when composing, and
responding to one (send or decline) when reading.
Request side (compose)
- New --request-receipt flag on +send / +reply / +reply-all / +forward /
+draft-create / +draft-edit. When set, the outgoing EML carries a
Disposition-Notification-To header (RFC 3798) addressed to the resolved
sender. Recipient mail clients may prompt the user, auto-send a receipt,
or silently ignore — delivery is not guaranteed.
- requireSenderForRequestReceipt gates the flag against a controlled
sender address resolved BEFORE the orig.headTo fallback in +reply /
+reply-all / +forward, so the DNT cannot silently land on someone else
in CC / shared-mailbox flows.
Response side
- +send-receipt: build a system-templated reply for messages carrying the
READ_RECEIPT_REQUEST label (-607). Subject / recipient / sent / read
time layout matches the Lark client; body is non-customizable — receipt
bodies are system templates by industry convention; free-form notes
belong in +reply. Risk:"high-risk-write" + --yes required.
- +decline-receipt: clear READ_RECEIPT_REQUEST without sending anything
(mirrors the client's "不发送" / "Don't send" button). Idempotent on
re-run; Risk:"write" — no --yes needed.
Read-path hints
- +message / +messages / +thread emit a stderr hint when surfacing a
mail carrying READ_RECEIPT_REQUEST, exposing BOTH response paths
(+send-receipt --yes / +decline-receipt) so agents present a real
choice to the user instead of silently auto-sending.
Guard rails
- +send / +reply / +reply-all / +forward stay draft-by-default and
require --confirm-send to send, gated by a dynamic scope check for
mail:user_mailbox.message:send (absent from the default scope set so
draft-only flows don't need the sensitive permission).
- All header-bound user input (sender / display name / recipient /
subject) goes through CR/LF rejection plus Bidi / zero-width / line-
separator guards, mirroring emlbuilder.validateHeaderValue, to block
header injection and visual spoofing.
- Hint output strips terminal control characters (CR, LF) from any
untrusted field embedded into the user-visible suggestion.
Backend coupling
- Outgoing receipt EML carries the private header
X-Lark-Read-Receipt-Mail: 1. The data-access backend parses it into
BodyExtra.IsReadReceiptMail; DraftSend then applies READ_RECEIPT_SENT
(-608) and clears READ_RECEIPT_REQUEST (-607) from the original
message, closing the client-side banner.
- en receipts require backend TCC SubjectPrefixListForAdvancedSearch to
include "Read Receipt:" for conversation-view aggregation; zh prefix
("已读回执:") is already configured.
Docs: new reference pages for +send-receipt / +decline-receipt;
--request-receipt noted on each compose-side reference; SKILL.md
workflow (section 9) describes the full privacy-safe decision tree on
both sides.
Tests cover emlbuilder DispositionNotificationTo / IsReadReceiptMail
helpers, receiptMetaLabels (zh / en), buildReceiptSubject, text and HTML
body generators (with HTML escaping and Bidi guards), header-injection
defenses, sender-resolution gating (CC-only / shared-mailbox regression),
hint emission paths, and the full +send-receipt / +decline-receipt happy
+ idempotent paths via httpmock.
397 lines
14 KiB
Go
397 lines
14 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package mail
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
)
|
|
|
|
// stubMailboxProfile registers a profile API stub that returns the given
|
|
// primary email address (or an empty response when primary is empty).
|
|
func stubMailboxProfile(reg *httpmock.Registry, primary string) {
|
|
data := map[string]interface{}{}
|
|
if primary != "" {
|
|
data["primary_email_address"] = primary
|
|
}
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/user_mailboxes/me/profile",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": data,
|
|
},
|
|
})
|
|
}
|
|
|
|
// stubGetMessageWithFormat registers a messages.get stub returning a minimal
|
|
// message suitable for reply / reply-all / forward. Subject / body / headers
|
|
// are fixed to deterministic values.
|
|
func stubGetMessageWithFormat(reg *httpmock.Registry, messageID string) {
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/user_mailboxes/me/messages/" + messageID,
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"message": map[string]interface{}{
|
|
"message_id": messageID,
|
|
"thread_id": "thread_abc",
|
|
"smtp_message_id": "<orig@smtp.example.com>",
|
|
"subject": "original subject",
|
|
"head_from": map[string]interface{}{
|
|
"mail_address": "bob@example.com",
|
|
"name": "Bob",
|
|
},
|
|
"to": []interface{}{
|
|
map[string]interface{}{"mail_address": "alice@example.com", "name": "Alice"},
|
|
},
|
|
"internal_date": "1700000000000",
|
|
"body_plain_text": base64.RawURLEncoding.EncodeToString([]byte("original body")),
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// registerDraftCaptureStubs wires the registry so drafts.create captures the
|
|
// posted raw EML (via Stub.CapturedBody) and drafts.send returns a
|
|
// successful send response. The returned Stub's CapturedBody contains the
|
|
// JSON body of the drafts.create request; decodeCapturedRawEML extracts the
|
|
// base64url-decoded EML from it.
|
|
func registerDraftCaptureStubs(reg *httpmock.Registry) *httpmock.Stub {
|
|
createStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/user_mailboxes/me/drafts",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"draft_id": "draft_001"},
|
|
},
|
|
}
|
|
reg.Register(createStub)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/user_mailboxes/me/drafts/draft_001/send",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"message_id": "msg_001",
|
|
"thread_id": "thread_abc",
|
|
},
|
|
},
|
|
})
|
|
return createStub
|
|
}
|
|
|
|
// decodeCapturedRawEML extracts and base64url-decodes the "raw" field from
|
|
// the captured drafts.create request body. Returns "" when the body is
|
|
// unavailable or malformed.
|
|
func decodeCapturedRawEML(t *testing.T, capturedBody []byte) string {
|
|
t.Helper()
|
|
s := string(capturedBody)
|
|
const key = `"raw":"`
|
|
idx := strings.Index(s, key)
|
|
if idx < 0 {
|
|
t.Fatalf(`missing "raw" field in captured body: %s`, s)
|
|
}
|
|
rest := s[idx+len(key):]
|
|
end := strings.IndexByte(rest, '"')
|
|
if end < 0 {
|
|
t.Fatalf(`malformed "raw" field in captured body: %s`, s)
|
|
}
|
|
decoded, err := base64.RawURLEncoding.DecodeString(rest[:end])
|
|
if err != nil {
|
|
// Try standard URL encoding as fallback.
|
|
decoded, err = base64.URLEncoding.DecodeString(rest[:end])
|
|
if err != nil {
|
|
t.Fatalf("failed to decode captured raw EML: %v", err)
|
|
}
|
|
}
|
|
return string(decoded)
|
|
}
|
|
|
|
// TestMailSend_RequestReceiptAddsHeader_Integration verifies that running
|
|
// `+send --request-receipt` end-to-end writes a Disposition-Notification-To
|
|
// header addressed to the sender into the outgoing draft's EML.
|
|
func TestMailSend_RequestReceiptAddsHeader_Integration(t *testing.T) {
|
|
f, stdout, _, reg := mailShortcutTestFactoryWithSendScope(t)
|
|
stubMailboxProfile(reg, "me@example.com")
|
|
createStub := registerDraftCaptureStubs(reg)
|
|
|
|
if err := runMountedMailShortcut(t, MailSend, []string{
|
|
"+send",
|
|
"--to", "bob@example.com",
|
|
"--subject", "hi",
|
|
"--body", "please confirm",
|
|
"--request-receipt",
|
|
"--confirm-send",
|
|
}, f, stdout); err != nil {
|
|
t.Fatalf("send failed: %v", err)
|
|
}
|
|
raw := decodeCapturedRawEML(t, createStub.CapturedBody)
|
|
// Pin the full header value so the From: header's me@example.com doesn't
|
|
// satisfy a substring check even when DNT is broken.
|
|
if !strings.Contains(raw, "Disposition-Notification-To: <me@example.com>") {
|
|
t.Errorf("expected DNT header addressed to sender; got EML:\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
|
|
// clear error, no HTTP call to drafts.create.
|
|
func TestMailSend_RequestReceiptNoSender_FailsValidation(t *testing.T) {
|
|
f, stdout, _, reg := mailShortcutTestFactoryWithSendScope(t)
|
|
stubMailboxProfile(reg, "") // profile returns no primary address
|
|
|
|
err := runMountedMailShortcut(t, MailSend, []string{
|
|
"+send",
|
|
"--to", "bob@example.com",
|
|
"--subject", "hi",
|
|
"--body", "body",
|
|
"--request-receipt",
|
|
"--confirm-send",
|
|
}, f, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected validation error for --request-receipt without resolvable sender")
|
|
}
|
|
if !strings.Contains(err.Error(), "--request-receipt") {
|
|
t.Errorf("error should mention --request-receipt, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestMailReply_RequestReceiptAddsHeader_Integration mirrors the +send test
|
|
// for +reply: verifies DNT ends up in the reply draft's EML.
|
|
func TestMailReply_RequestReceiptAddsHeader_Integration(t *testing.T) {
|
|
f, stdout, _, reg := mailShortcutTestFactoryWithSendScope(t)
|
|
stubMailboxProfile(reg, "me@example.com")
|
|
stubGetMessageWithFormat(reg, "msg_orig")
|
|
createStub := registerDraftCaptureStubs(reg)
|
|
|
|
if err := runMountedMailShortcut(t, MailReply, []string{
|
|
"+reply",
|
|
"--message-id", "msg_orig",
|
|
"--body", "got it",
|
|
"--request-receipt",
|
|
"--confirm-send",
|
|
}, f, stdout); err != nil {
|
|
t.Fatalf("reply failed: %v", err)
|
|
}
|
|
raw := decodeCapturedRawEML(t, createStub.CapturedBody)
|
|
if !strings.Contains(raw, "Disposition-Notification-To: <me@example.com>") {
|
|
t.Errorf("expected DNT header addressed to sender in reply EML; got:\n%s", raw)
|
|
}
|
|
}
|
|
|
|
// TestMailReplyAll_RequestReceiptAddsHeader_Integration covers the +reply-all
|
|
// branch — reply-all had an extra concern because senderEmail falls back to
|
|
// orig.headTo when resolveComposeSenderEmail returns "". The gating added in
|
|
// this PR moves requireSenderForRequestReceipt before that fallback, so the
|
|
// receipt only resolves against an explicitly configured sender.
|
|
func TestMailReplyAll_RequestReceiptAddsHeader_Integration(t *testing.T) {
|
|
f, stdout, _, reg := mailShortcutTestFactoryWithSendScope(t)
|
|
stubMailboxProfile(reg, "me@example.com")
|
|
stubGetMessageWithFormat(reg, "msg_orig")
|
|
createStub := registerDraftCaptureStubs(reg)
|
|
|
|
if err := runMountedMailShortcut(t, MailReplyAll, []string{
|
|
"+reply-all",
|
|
"--message-id", "msg_orig",
|
|
"--body", "ack",
|
|
"--request-receipt",
|
|
"--confirm-send",
|
|
}, f, stdout); err != nil {
|
|
t.Fatalf("reply-all failed: %v", err)
|
|
}
|
|
raw := decodeCapturedRawEML(t, createStub.CapturedBody)
|
|
if !strings.Contains(raw, "Disposition-Notification-To: <me@example.com>") {
|
|
t.Errorf("expected DNT header addressed to sender in reply-all EML; got:\n%s", raw)
|
|
}
|
|
}
|
|
|
|
// stubGetMessageWithLabels registers a messages.get stub for the decline-
|
|
// receipt flow: the minimum fields the Execute path reads are message_id and
|
|
// label_ids. Callers supply the label list so tests can exercise both the
|
|
// "label present" and "already cleared" branches.
|
|
func stubGetMessageWithLabels(reg *httpmock.Registry, messageID string, labels []interface{}) {
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/user_mailboxes/me/messages/" + messageID,
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"message": map[string]interface{}{
|
|
"message_id": messageID,
|
|
"label_ids": labels,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// TestMailDeclineReceipt_RemovesLabel_Integration exercises the happy path:
|
|
// a message carries the READ_RECEIPT_REQUEST label → +decline-receipt issues
|
|
// a PUT user_mailbox.message.modify (the public OpenAPI endpoint for the
|
|
// MessageModify RPC that the Lark client's "不发送" button also triggers
|
|
// internally) whose body removes exactly "READ_RECEIPT_REQUEST". Endpoint
|
|
// (single-message modify, not batch) and label-id form (symbolic name —
|
|
// the public OpenAPI accepts the symbolic form and translates to the
|
|
// internal numeric id server-side; the internal RPC uses -607 directly)
|
|
// are both pinned so regressions get caught here.
|
|
func TestMailDeclineReceipt_RemovesLabel_Integration(t *testing.T) {
|
|
f, stdout, _, reg := mailShortcutTestFactoryWithSendScope(t)
|
|
stubGetMessageWithLabels(reg, "msg_orig", []interface{}{"UNREAD", "READ_RECEIPT_REQUEST"})
|
|
|
|
modifyStub := &httpmock.Stub{
|
|
Method: "PUT",
|
|
URL: "/user_mailboxes/me/messages/msg_orig/modify",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{},
|
|
},
|
|
}
|
|
reg.Register(modifyStub)
|
|
|
|
if err := runMountedMailShortcut(t, MailDeclineReceipt, []string{
|
|
"+decline-receipt",
|
|
"--message-id", "msg_orig",
|
|
}, f, stdout); err != nil {
|
|
t.Fatalf("decline-receipt failed: %v", err)
|
|
}
|
|
|
|
body := string(modifyStub.CapturedBody)
|
|
for _, want := range []string{
|
|
`"remove_label_ids"`,
|
|
`"READ_RECEIPT_REQUEST"`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("expected modify body to contain %q; got:\n%s", want, body)
|
|
}
|
|
}
|
|
// Guard: the public OpenAPI expects the symbolic label name; "-607"
|
|
// (the internal numeric id used by the MessageModify RPC directly) is
|
|
// not in the OpenAPI contract and must not leak into the request.
|
|
if strings.Contains(body, `"-607"`) {
|
|
t.Errorf("modify body should send symbolic \"READ_RECEIPT_REQUEST\", not internal numeric id; got:\n%s", body)
|
|
}
|
|
// Single-message modify has no message_ids array (that's the batch
|
|
// endpoint's shape); assert we didn't accidentally keep the old payload.
|
|
if strings.Contains(body, `"message_ids"`) {
|
|
t.Errorf("single-message modify body should not contain message_ids (that's batch endpoint shape); got:\n%s", body)
|
|
}
|
|
|
|
out := stdout.String()
|
|
if !strings.Contains(out, `"declined":true`) && !strings.Contains(out, `"declined": true`) {
|
|
t.Errorf("expected declined=true in output; got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// TestMailDeclineReceipt_AlreadyCleared_Integration verifies idempotence:
|
|
// when the READ_RECEIPT_REQUEST label is already absent the shortcut
|
|
// returns success without issuing a modify call.
|
|
func TestMailDeclineReceipt_AlreadyCleared_Integration(t *testing.T) {
|
|
f, stdout, _, reg := mailShortcutTestFactoryWithSendScope(t)
|
|
stubGetMessageWithLabels(reg, "msg_orig", []interface{}{"UNREAD"})
|
|
|
|
// Intentionally not registering the modify stub: if Execute issues the
|
|
// POST anyway, httpmock will fail the test loudly instead of silently
|
|
// sending an unmocked request to the network.
|
|
|
|
if err := runMountedMailShortcut(t, MailDeclineReceipt, []string{
|
|
"+decline-receipt",
|
|
"--message-id", "msg_orig",
|
|
}, f, stdout); err != nil {
|
|
t.Fatalf("decline-receipt failed: %v", err)
|
|
}
|
|
|
|
out := stdout.String()
|
|
if !strings.Contains(out, "already_cleared") {
|
|
t.Errorf("expected already_cleared=true in output; got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, `"declined":false`) && !strings.Contains(out, `"declined": false`) {
|
|
t.Errorf("expected declined=false in output; got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// TestMailForward_RequestReceiptAddsHeader_Integration covers the same path
|
|
// on +forward.
|
|
func TestMailForward_RequestReceiptAddsHeader_Integration(t *testing.T) {
|
|
f, stdout, _, reg := mailShortcutTestFactoryWithSendScope(t)
|
|
stubMailboxProfile(reg, "me@example.com")
|
|
stubGetMessageWithFormat(reg, "msg_orig")
|
|
createStub := registerDraftCaptureStubs(reg)
|
|
|
|
if err := runMountedMailShortcut(t, MailForward, []string{
|
|
"+forward",
|
|
"--message-id", "msg_orig",
|
|
"--to", "eve@example.com",
|
|
"--body", "fyi",
|
|
"--request-receipt",
|
|
"--confirm-send",
|
|
}, f, stdout); err != nil {
|
|
t.Fatalf("forward failed: %v", err)
|
|
}
|
|
raw := decodeCapturedRawEML(t, createStub.CapturedBody)
|
|
if !strings.Contains(raw, "Disposition-Notification-To: <me@example.com>") {
|
|
t.Errorf("expected DNT header addressed to sender in forward EML; got:\n%s", raw)
|
|
}
|
|
}
|
|
|
|
// TestMailReply_RequestReceiptNoSender_DoesNotFallBackToOrigHeadTo guards
|
|
// the CC-only / shared-mailbox regression: when --request-receipt is set
|
|
// and no sender can be explicitly resolved (empty profile + no --from),
|
|
// +reply MUST fail validation instead of silently falling back to
|
|
// orig.headTo (which is some other recipient from the original message
|
|
// — in this stub, alice@example.com, the original "To"). Pre-fix, the
|
|
// fallback address satisfied the non-empty check and the DNT header was
|
|
// silently addressed to the wrong person.
|
|
func TestMailReply_RequestReceiptNoSender_DoesNotFallBackToOrigHeadTo(t *testing.T) {
|
|
f, stdout, _, reg := mailShortcutTestFactoryWithSendScope(t)
|
|
stubMailboxProfile(reg, "") // profile has no primary → resolvedSender == ""
|
|
stubGetMessageWithFormat(reg, "msg_orig")
|
|
// Intentionally not registering drafts.create: if Execute proceeds past
|
|
// validation, httpmock fails the test loudly instead of a silent pass.
|
|
|
|
err := runMountedMailShortcut(t, MailReply, []string{
|
|
"+reply",
|
|
"--message-id", "msg_orig",
|
|
"--body", "ack",
|
|
"--request-receipt",
|
|
"--confirm-send",
|
|
}, f, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected validation error for --request-receipt with no resolvable sender; got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "--request-receipt") {
|
|
t.Errorf("error should mention --request-receipt, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestMailForward_RequestReceiptNoSender_DoesNotFallBackToOrigHeadTo is
|
|
// the +forward counterpart to the +reply test above — same regression,
|
|
// same fix, same assertion.
|
|
func TestMailForward_RequestReceiptNoSender_DoesNotFallBackToOrigHeadTo(t *testing.T) {
|
|
f, stdout, _, reg := mailShortcutTestFactoryWithSendScope(t)
|
|
stubMailboxProfile(reg, "")
|
|
stubGetMessageWithFormat(reg, "msg_orig")
|
|
|
|
err := runMountedMailShortcut(t, MailForward, []string{
|
|
"+forward",
|
|
"--message-id", "msg_orig",
|
|
"--to", "eve@example.com",
|
|
"--body", "fyi",
|
|
"--request-receipt",
|
|
"--confirm-send",
|
|
}, f, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected validation error for --request-receipt with no resolvable sender; got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "--request-receipt") {
|
|
t.Errorf("error should mention --request-receipt, got: %v", err)
|
|
}
|
|
}
|