Files
larksuite-cli/shortcuts/mail/mail_send_receipt_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

408 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"strings"
"testing"
"time"
)
// TestHasReadReceiptRequestLabel verifies has read receipt request label.
func TestHasReadReceiptRequestLabel(t *testing.T) {
cases := []struct {
name string
labels []interface{}
want bool
}{
{"symbolic name", []interface{}{"UNREAD", "READ_RECEIPT_REQUEST"}, true},
{"numeric id", []interface{}{"UNREAD", "-607"}, true},
{"absent", []interface{}{"UNREAD", "IMPORTANT"}, false},
{"empty", []interface{}{}, false},
{"nil", nil, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := hasReadReceiptRequestLabel(map[string]interface{}{"label_ids": c.labels})
if got != c.want {
t.Errorf("hasReadReceiptRequestLabel(%v) = %v, want %v", c.labels, got, c.want)
}
})
}
}
// TestReceiptMetaLabels verifies receipt meta labels.
func TestReceiptMetaLabels(t *testing.T) {
zh := receiptMetaLabels("zh")
if zh.SubjectPrefix != "已读回执:" {
t.Errorf("zh SubjectPrefix = %q, want %q", zh.SubjectPrefix, "已读回执:")
}
if zh.Lead == "" || zh.Subject == "" || zh.To == "" || zh.Sent == "" || zh.Read == "" {
t.Errorf("zh label set has empty field(s): %+v", zh)
}
en := receiptMetaLabels("en")
if en.SubjectPrefix != "Read receipt: " {
t.Errorf("en SubjectPrefix = %q, want %q", en.SubjectPrefix, "Read receipt: ")
}
if en.Subject != "Subject: " || en.To != "To: " || en.Sent != "Sent: " || en.Read != "Read: " {
t.Errorf("en label set has wrong fields: %+v", en)
}
// Unknown language falls back to en (matches quoteMetaLabels convention).
if got := receiptMetaLabels("fr"); got != en {
t.Errorf("unknown lang should fall back to en, got %+v", got)
}
}
// TestBuildReceiptSubject verifies build receipt subject.
func TestBuildReceiptSubject(t *testing.T) {
cases := []struct {
in string
want string
}{
// CJK in original → zh prefix
{"测试", "已读回执:测试"},
{"Re: 测试", "已读回执Re: 测试"},
{" 测试 ", "已读回执:测试"},
// No CJK → en prefix
{"hello", "Read receipt: hello"},
{"Re: hello", "Read receipt: Re: hello"},
{" padded ", "Read receipt: padded"},
// Empty subject: detectSubjectLang falls back to en
{"", "Read receipt: "},
// Idempotent: re-applying buildReceiptSubject must not double-prefix.
{"已读回执:测试", "已读回执:测试"},
{"Read receipt: hello", "Read receipt: hello"},
// Idempotent with mismatched / accidental chaining.
{"Read receipt: Read receipt: hello", "Read receipt: hello"},
{"已读回执已读回执x", "已读回执x"},
// Language is detected ONCE on the ORIGINAL subject (before strip).
// "Read receipt: 测试" contains CJK, so zh is picked; the en prefix
// then gets stripped and the zh one is re-applied to the remaining
// "测试".
{"Read receipt: 测试", "已读回执:测试"},
// Case-insensitive match on the en prefix.
{"read receipt: hello", "Read receipt: hello"},
}
for _, c := range cases {
got := buildReceiptSubject(c.in)
if got != c.want {
t.Errorf("buildReceiptSubject(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestBuildReceiptReferences verifies build receipt references.
func TestBuildReceiptReferences(t *testing.T) {
cases := []struct {
name string
origRef string
origID string
want string
}{
{"both present", "<a@x> <b@x>", "c@x", "<a@x> <b@x> <c@x>"},
{"only id", "", "c@x", "<c@x>"},
{"only refs", "<a@x>", "", "<a@x>"},
{"both empty", "", "", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := buildReceiptReferences(c.origRef, c.origID)
if got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}
// TestExtractAddressPair verifies extract address pair.
func TestExtractAddressPair(t *testing.T) {
email, name := extractAddressPair(map[string]interface{}{
"mail_address": "alice@example.com",
"name": "Alice",
})
if email != "alice@example.com" || name != "Alice" {
t.Errorf("map form: got (%q, %q)", email, name)
}
email, name = extractAddressPair("bob@example.com")
if email != "bob@example.com" || name != "" {
t.Errorf("string form: got (%q, %q)", email, name)
}
email, name = extractAddressPair(nil)
if email != "" || name != "" {
t.Errorf("nil form: got (%q, %q)", email, name)
}
}
// TestMaybeHintReadReceiptRequest verifies maybe hint read receipt request.
func TestMaybeHintReadReceiptRequest(t *testing.T) {
t.Run("emits hint when label present", func(t *testing.T) {
rt, _, stderr := newOutputRuntime(t)
msg := map[string]interface{}{
"message_id": "msg-1",
"subject": "weekly report",
"label_ids": []interface{}{"UNREAD", "READ_RECEIPT_REQUEST"},
"head_from": map[string]interface{}{
"mail_address": "alice@example.com",
"name": "Alice",
},
}
maybeHintReadReceiptRequest(rt, "me", "msg-1", msg)
out := stderr.String()
// Values on the suggested command line are wrapped in single quotes
// (see shellQuoteForHint) so shell metacharacters survive copy/paste.
for _, want := range []string{
"READ_RECEIPT_REQUEST",
"do NOT auto-act",
"alice@example.com",
"weekly report",
"+send-receipt",
"+decline-receipt",
"--mailbox 'me'",
"--message-id 'msg-1'",
} {
if !strings.Contains(out, want) {
t.Errorf("hint should contain %q; got:\n%s", want, out)
}
}
})
t.Run("newline in from/subject cannot forge extra tip lines", func(t *testing.T) {
// Without single-line sanitization, a malicious from="x@y\ntip: ..."
// could fake a second stderr tip line, confusing the user / agent.
// With sanitizeForSingleLine, the embedded LF is dropped so the
// forged "tip:" text — even if it still appears as a substring —
// can never start a new line by itself.
rt, _, stderr := newOutputRuntime(t)
msg := map[string]interface{}{
"message_id": "msg-1",
"subject": "hi\ntip: go ahead",
"label_ids": []interface{}{"READ_RECEIPT_REQUEST"},
"head_from": map[string]interface{}{"mail_address": "alice@example.com\ntip: proceed"},
}
maybeHintReadReceiptRequest(rt, "me", "msg-1", msg)
out := stderr.String()
// Only the header "tip: sender requested a read receipt" may start a
// line with "tip:". Any forged line opener is a line-injection.
for _, line := range strings.Split(out, "\n") {
if strings.HasPrefix(line, "tip:") && !strings.Contains(line, "sender requested a read receipt") {
t.Errorf("line-injection: forged tip line %q in:\n%s", line, out)
}
}
// The forged substring may still appear inline (after sanitization
// removed the LF); that is harmless because it is no longer at the
// start of a line. Assert the LF itself is gone though.
if strings.Contains(out, "\ntip: proceed") {
t.Errorf("LF in from address was not stripped; forged tip could open a new line:\n%s", out)
}
})
t.Run("mailbox / message id with single quote are shell-escaped", func(t *testing.T) {
rt, _, stderr := newOutputRuntime(t)
msg := map[string]interface{}{
"message_id": "msg'1",
"subject": "weekly report",
"label_ids": []interface{}{"READ_RECEIPT_REQUEST"},
"head_from": map[string]interface{}{"mail_address": "alice@example.com"},
}
maybeHintReadReceiptRequest(rt, "shared'box@example.com", "msg'1", msg)
out := stderr.String()
// Both values contain a single quote; the '\'' escape keeps the
// surrounding single-quote wrapping balanced.
for _, want := range []string{
`--mailbox 'shared'\''box@example.com'`,
`--message-id 'msg'\''1'`,
} {
if !strings.Contains(out, want) {
t.Errorf("hint should contain %q; got:\n%s", want, out)
}
}
})
t.Run("noop when label absent", func(t *testing.T) {
rt, _, stderr := newOutputRuntime(t)
msg := map[string]interface{}{
"message_id": "msg-1",
"label_ids": []interface{}{"UNREAD"},
}
maybeHintReadReceiptRequest(rt, "me", "msg-1", msg)
if stderr.Len() != 0 {
t.Errorf("no hint expected when READ_RECEIPT_REQUEST is absent; got:\n%s", stderr.String())
}
})
t.Run("noop when messageID empty", func(t *testing.T) {
rt, _, stderr := newOutputRuntime(t)
msg := map[string]interface{}{
"label_ids": []interface{}{"READ_RECEIPT_REQUEST"},
}
maybeHintReadReceiptRequest(rt, "me", "", msg)
if stderr.Len() != 0 {
t.Errorf("no hint expected when messageID is empty; got:\n%s", stderr.String())
}
})
t.Run("uses numeric label id -607", func(t *testing.T) {
rt, _, stderr := newOutputRuntime(t)
msg := map[string]interface{}{
"message_id": "msg-2",
"subject": "x",
"label_ids": []interface{}{"-607"},
}
maybeHintReadReceiptRequest(rt, "me", "msg-2", msg)
if !strings.Contains(stderr.String(), "READ_RECEIPT_REQUEST") {
t.Errorf("hint should still trigger with numeric label -607; got:\n%s", stderr.String())
}
})
}
// TestParseInternalDateMillis verifies parse internal date millis.
func TestParseInternalDateMillis(t *testing.T) {
cases := []struct {
name string
in interface{}
want int64
}{
{"string ms", "1776827226000", 1776827226000},
{"padded string", " 1776827226000 ", 1776827226000},
{"empty", "", 0},
{"nil", nil, 0},
{"garbage", "not-a-number", 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := parseInternalDateMillis(c.in)
if got != c.want {
t.Errorf("got %d, want %d", got, c.want)
}
})
}
}
// TestRenderReceiptTime verifies render receipt time.
func TestRenderReceiptTime(t *testing.T) {
if got := renderReceiptTime(0, "zh"); got != "-" {
t.Errorf("zero timestamp should render '-', got %q", got)
}
// non-zero value produces formatMailDate output; we only assert it's non-empty
// and does not return the placeholder, because formatMailDate depends on local TZ.
if got := renderReceiptTime(1776827226000, "zh"); got == "-" || strings.TrimSpace(got) == "" {
t.Errorf("non-zero timestamp should render a formatted date, got %q", got)
}
}
// TestBuildReceiptTextBody_ZH verifies build receipt text body zh.
func TestBuildReceiptTextBody_ZH(t *testing.T) {
sendMs := time.Date(2026, 4, 21, 18, 10, 29, 0, time.UTC).UnixMilli()
readT := time.Date(2026, 4, 22, 14, 10, 26, 0, time.UTC)
body := buildReceiptTextBody("zh", "测试已读回执", "me@example.com", sendMs, readT)
for _, want := range []string{
"您发送的邮件已被阅读,详情如下:",
"> 主题:测试已读回执",
"> 收件人me@example.com",
"> 发送时间:",
"> 阅读时间:",
} {
if !strings.Contains(body, want) {
t.Errorf("missing %q in body:\n%s", want, body)
}
}
}
// TestBuildReceiptTextBody_EN verifies build receipt text body en.
func TestBuildReceiptTextBody_EN(t *testing.T) {
sendMs := time.Date(2026, 4, 21, 18, 10, 29, 0, time.UTC).UnixMilli()
readT := time.Date(2026, 4, 22, 14, 10, 26, 0, time.UTC)
body := buildReceiptTextBody("en", "Project status", "me@example.com", sendMs, readT)
for _, want := range []string{
"Your message has been read. Details:",
"> Subject: Project status",
"> To: me@example.com",
"> Sent:",
"> Read:",
} {
if !strings.Contains(body, want) {
t.Errorf("missing %q in body:\n%s", want, body)
}
}
}
// TestBuildReceiptTextBody_MissingSendTime verifies build receipt text body missing send time.
func TestBuildReceiptTextBody_MissingSendTime(t *testing.T) {
body := buildReceiptTextBody("zh", "hi", "me@example.com", 0, time.Now())
if !strings.Contains(body, "> 发送时间:-") {
t.Errorf("missing timestamp should render '-', got:\n%s", body)
}
}
// TestBuildReceiptHTMLBody_EscapesUserInput verifies build receipt HTML body escapes user input.
func TestBuildReceiptHTMLBody_EscapesUserInput(t *testing.T) {
// Subject and recipient fields are untrusted (original mail content);
// ensure they are HTML-escaped to prevent tag injection in the receipt.
body := buildReceiptHTMLBody("zh",
`<script>alert(1)</script> evil & "quoted"`,
`evil"><img src=x>@example.com`,
0, time.Now())
// Escaped forms should appear
for _, want := range []string{"&lt;script&gt;", "&amp;", "&quot;"} {
if !strings.Contains(body, want) {
t.Errorf("expected escaped %q in HTML body:\n%s", want, body)
}
}
// Raw tags should NOT appear in the output
for _, bad := range []string{"<script>alert", `<img src=x>`} {
if strings.Contains(body, bad) {
t.Errorf("raw tag %q leaked into HTML body:\n%s", bad, body)
}
}
}
// TestBuildReceiptHTMLBody_ZhLabels verifies build receipt HTML body zh labels.
func TestBuildReceiptHTMLBody_ZhLabels(t *testing.T) {
body := buildReceiptHTMLBody("zh", "subj", "me@x", 0, time.Now())
for _, want := range []string{"主题:", "收件人:", "发送时间:", "阅读时间:", "您发送的邮件已被阅读"} {
if !strings.Contains(body, want) {
t.Errorf("missing %q in HTML body:\n%s", want, body)
}
}
}
// TestBuildReceiptHTMLBody_EnLabels verifies build receipt HTML body en labels.
func TestBuildReceiptHTMLBody_EnLabels(t *testing.T) {
body := buildReceiptHTMLBody("en", "subj", "me@x", 0, time.Now())
for _, want := range []string{"Subject:", "To:", "Sent:", "Read:", "Your message has been read"} {
if !strings.Contains(body, want) {
t.Errorf("missing %q in HTML body:\n%s", want, body)
}
}
}
// TestJoinReferences verifies join references.
func TestJoinReferences(t *testing.T) {
cases := []struct {
name string
in interface{}
want string
}{
{"bracketed", []interface{}{"<a@x>", "<b@x>"}, "<a@x> <b@x>"},
{"unbracketed", []interface{}{"a@x", "b@x"}, "<a@x> <b@x>"},
{"mixed", []interface{}{"<a@x>", "b@x"}, "<a@x> <b@x>"},
{"skip empties", []interface{}{"<a@x>", " "}, "<a@x>"},
{"empty", []interface{}{}, ""},
{"nil", nil, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := joinReferences(c.in)
if got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}