Files
larksuite-cli/shortcuts/mail/mail_request_receipt_integration_test.go
xzcong0820 d92f0a2204 feat(mail): add read receipt support (--request-receipt, +send-receipt, +decline-receipt)
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.
2026-04-24 14:26:17 +08:00

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)
}
}