mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 09:11:44 +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.
408 lines
14 KiB
Go
408 lines
14 KiB
Go
// 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{"<script>", "&", """} {
|
||
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)
|
||
}
|
||
})
|
||
}
|
||
}
|