From c1814cb325ecc1d97dd070b74ee0069dee422cc0 Mon Sep 17 00:00:00 2001 From: xuzhuocong Date: Wed, 22 Apr 2026 08:24:24 +0000 Subject: [PATCH] feat: add read receipt support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end read-receipt support covering both sides of the flow: Request side (compose): - Add --request-receipt flag to +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 sender. Recipient mail clients may prompt the user, auto-send a receipt, or silently ignore — delivery is not guaranteed. Response side: - Add +send-receipt shortcut. Given --message-id of a mail that carries the READ_RECEIPT_REQUEST label (-607), it builds an auto- generated reply whose subject, recipient, send time and read time match the Lark client's receipt layout. Callers cannot customize the body — receipt bodies are system-generated templates in the industry norm; free-form notes belong in +reply. - Subject prefix is picked by detectSubjectLang on the original subject: "已读回执:" for CJK, "Read Receipt: " otherwise. Labels are centralized in receiptMetaLabels(lang), mirroring the quoteMetaLabels pattern used by +reply / +forward. For en receipts to aggregate with the original mail in conversation view, the backend TCC SubjectPrefixListForAdvancedSearch must include "Read Receipt:"; zh is already configured. - The outgoing 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 removes READ_RECEIPT_REQUEST (-607) from the original message, closing the client-side Banner. Guard rails against silent auto-sends: - +send-receipt is marked Risk="high-risk-write" and requires --yes; +send / +reply / +reply-all / +forward stay draft-by-default and require --confirm-send, which is further gated by a dynamic scope check for mail:user_mailbox.message:send (absent from the default scope set). - +message, +messages, +thread emit a stderr hint when a message they surface carries READ_RECEIPT_REQUEST, explicitly telling callers to ask the user before responding with +send-receipt --yes. This avoids silent auto-sends from agent callers that skipped the skill reference. Docs: new reference page for +send-receipt; --request-receipt noted on each compose-side reference; SKILL.md index updated. Tests cover the emlbuilder DispositionNotificationTo / IsReadReceiptMail helpers, receiptMetaLabels (zh / en / fallback), buildReceiptSubject, text and HTML body generators, HTML escaping, confirm-send scope validation, hint emission paths, and the +send-receipt end-to-end flow. --- shortcuts/mail/draft/service.go | 31 ++ shortcuts/mail/emlbuilder/builder.go | 97 ++++- shortcuts/mail/emlbuilder/builder_test.go | 272 ++++++++++++ shortcuts/mail/helpers.go | 274 +++++++++++- shortcuts/mail/helpers_test.go | 187 ++++++++ shortcuts/mail/mail_decline_receipt.go | 97 +++++ shortcuts/mail/mail_decline_receipt_test.go | 146 +++++++ shortcuts/mail/mail_draft_create.go | 31 ++ shortcuts/mail/mail_draft_create_test.go | 69 +++ shortcuts/mail/mail_draft_edit.go | 49 ++- shortcuts/mail/mail_forward.go | 21 +- shortcuts/mail/mail_message.go | 3 + shortcuts/mail/mail_messages.go | 7 + shortcuts/mail/mail_reply.go | 21 +- shortcuts/mail/mail_reply_all.go | 21 +- .../mail_request_receipt_integration_test.go | 396 +++++++++++++++++ shortcuts/mail/mail_send.go | 9 + shortcuts/mail/mail_send_receipt.go | 363 ++++++++++++++++ shortcuts/mail/mail_send_receipt_test.go | 407 ++++++++++++++++++ shortcuts/mail/mail_thread.go | 20 + shortcuts/mail/shortcuts.go | 2 + skill-template/domains/mail.md | 4 + skills/lark-mail/SKILL.md | 27 +- .../references/lark-mail-decline-receipt.md | 115 +++++ .../references/lark-mail-draft-create.md | 1 + .../references/lark-mail-draft-edit.md | 1 + .../lark-mail/references/lark-mail-forward.md | 1 + .../references/lark-mail-reply-all.md | 1 + .../lark-mail/references/lark-mail-reply.md | 1 + .../references/lark-mail-send-receipt.md | 120 ++++++ skills/lark-mail/references/lark-mail-send.md | 1 + 31 files changed, 2762 insertions(+), 33 deletions(-) create mode 100644 shortcuts/mail/mail_decline_receipt.go create mode 100644 shortcuts/mail/mail_decline_receipt_test.go create mode 100644 shortcuts/mail/mail_request_receipt_integration_test.go create mode 100644 shortcuts/mail/mail_send_receipt.go create mode 100644 shortcuts/mail/mail_send_receipt_test.go create mode 100644 skills/lark-mail/references/lark-mail-decline-receipt.md create mode 100644 skills/lark-mail/references/lark-mail-send-receipt.md diff --git a/shortcuts/mail/draft/service.go b/shortcuts/mail/draft/service.go index 9c0cf4202..1bb74a9c6 100644 --- a/shortcuts/mail/draft/service.go +++ b/shortcuts/mail/draft/service.go @@ -11,6 +11,9 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) +// mailboxPath joins mailboxID and the given segments under the +// /open-apis/mail/v1/user_mailboxes/ root, URL-escaping each component. +// Empty segments are skipped. func mailboxPath(mailboxID string, segments ...string) string { parts := make([]string, 0, len(segments)+1) parts = append(parts, url.PathEscape(mailboxID)) @@ -23,6 +26,10 @@ func mailboxPath(mailboxID string, segments ...string) string { return "/open-apis/mail/v1/user_mailboxes/" + strings.Join(parts, "/") } +// GetRaw fetches the raw EML of a draft via drafts.get(format=raw) and +// returns the draft ID alongside the EML. If the backend response omits +// draft_id, the input draftID is echoed back so callers always have a +// non-empty identifier to round-trip. func GetRaw(runtime *common.RuntimeContext, mailboxID, draftID string) (DraftRaw, error) { data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "drafts", draftID), map[string]interface{}{"format": "raw"}, nil) if err != nil { @@ -42,6 +49,11 @@ func GetRaw(runtime *common.RuntimeContext, mailboxID, draftID string) (DraftRaw }, nil } +// CreateWithRaw creates a draft in mailboxID from a pre-built base64url-encoded +// EML payload and returns the server-assigned draft ID along with the +// optional preview reference URL. Use this when the caller has already +// assembled the EML with emlbuilder; for high-level compose paths use the +// MailDraftCreate shortcut instead. func CreateWithRaw(runtime *common.RuntimeContext, mailboxID, rawEML string) (DraftResult, error) { data, err := runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts"), nil, map[string]interface{}{"raw": rawEML}) if err != nil { @@ -57,6 +69,12 @@ func CreateWithRaw(runtime *common.RuntimeContext, mailboxID, rawEML string) (Dr }, nil } +// UpdateWithRaw overwrites an existing draft's content with a pre-built +// base64url-encoded EML. Existing headers / body / attachments in the draft +// are replaced wholesale; callers that want to patch individual parts should +// use draftpkg.Apply on a parsed snapshot instead. The returned DraftResult +// carries the (possibly re-issued) draft ID and the preview reference URL +// when the backend provides one. func UpdateWithRaw(runtime *common.RuntimeContext, mailboxID, draftID, rawEML string) (DraftResult, error) { data, err := runtime.CallAPI("PUT", mailboxPath(mailboxID, "drafts", draftID), nil, map[string]interface{}{"raw": rawEML}) if err != nil { @@ -72,6 +90,10 @@ func UpdateWithRaw(runtime *common.RuntimeContext, mailboxID, draftID, rawEML st }, nil } +// Send dispatches a previously created draft. When sendTime is a non-empty +// Unix-seconds string the backend schedules delivery; otherwise delivery is +// immediate. The returned map is the raw API response body, typically +// including message_id / thread_id / recall_status. func Send(runtime *common.RuntimeContext, mailboxID, draftID, sendTime string) (map[string]interface{}, error) { var bodyParams map[string]interface{} if sendTime != "" { @@ -80,6 +102,9 @@ func Send(runtime *common.RuntimeContext, mailboxID, draftID, sendTime string) ( return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, bodyParams) } +// extractDraftID returns the first non-empty draft identifier found in the +// API response. Looks at draft_id / id at the top level, then recurses into a +// nested "draft" object. Returns "" when no identifier is present. func extractDraftID(data map[string]interface{}) string { if id, ok := data["draft_id"].(string); ok && strings.TrimSpace(id) != "" { return strings.TrimSpace(id) @@ -93,6 +118,9 @@ func extractDraftID(data map[string]interface{}) string { return "" } +// extractRawEML returns the base64url-encoded raw EML from the response, +// looking at top-level "raw", a nested "message.raw", or a nested "draft" +// object. Returns "" when no EML is present. func extractRawEML(data map[string]interface{}) string { if raw, ok := data["raw"].(string); ok && strings.TrimSpace(raw) != "" { return strings.TrimSpace(raw) @@ -108,6 +136,9 @@ func extractRawEML(data map[string]interface{}) string { return "" } +// extractReference returns the optional preview "reference" URL from the +// response, recursing into a nested "draft" object when present. Returns "" +// when no reference is present. func extractReference(data map[string]interface{}) string { if data == nil { return "" diff --git a/shortcuts/mail/emlbuilder/builder.go b/shortcuts/mail/emlbuilder/builder.go index 2b07637de..c8b5f83b4 100644 --- a/shortcuts/mail/emlbuilder/builder.go +++ b/shortcuts/mail/emlbuilder/builder.go @@ -73,26 +73,28 @@ func readFile(fio fileio.FileIO, path string) ([]byte, error) { // All setter methods return a copy of the Builder (immutable/fluent style), // so a base builder can be reused across multiple goroutines safely. type Builder struct { - fio fileio.FileIO // injected via WithFileIO; must be set before AddFile* calls - from mail.Address - to []mail.Address - cc []mail.Address - bcc []mail.Address - replyTo []mail.Address - subject string - date time.Time - messageID string - inReplyTo string // raw value, without angle brackets - references string // space-separated list of message IDs, with angle brackets - lmsReplyToMessageID string // Lark internal message_id of the original message - textBody []byte - htmlBody []byte - calendarBody []byte - attachments []attachment - inlines []inline - extraHeaders [][2]string // ordered list of [name, value] pairs - allowNoRecipients bool // when true, Build() skips the recipient check (for drafts) - err error + fio fileio.FileIO // injected via WithFileIO; must be set before AddFile* calls + from mail.Address + to []mail.Address + cc []mail.Address + bcc []mail.Address + replyTo []mail.Address + dispositionNotificationTo []mail.Address + subject string + date time.Time + messageID string + inReplyTo string // raw value, without angle brackets + references string // space-separated list of message IDs, with angle brackets + lmsReplyToMessageID string // Lark internal message_id of the original message + textBody []byte + htmlBody []byte + calendarBody []byte + attachments []attachment + inlines []inline + extraHeaders [][2]string // ordered list of [name, value] pairs + allowNoRecipients bool // when true, Build() skips the recipient check (for drafts) + isReadReceiptMail bool // when true, Build() writes X-Lark-Read-Receipt-Mail: 1 + err error } // WithFileIO returns a copy of b with the given FileIO. @@ -101,6 +103,9 @@ func (b Builder) WithFileIO(fio fileio.FileIO) Builder { return b } +// attachment is a regular (non-inline) MIME attachment — bytes plus MIME +// metadata — accumulated on the Builder and serialized under the +// multipart/mixed outer envelope. type attachment struct { content []byte contentType string @@ -290,6 +295,36 @@ func (b Builder) ReplyTo(name, addr string) Builder { return cp } +// DispositionNotificationTo appends an address to the Disposition-Notification-To header, +// which requests a Message Disposition Notification (MDN, read receipt) from the recipient's +// mail user agent (RFC 3798). name may be empty. +// +// Recipients' clients are not obliged to honour this header; user agents commonly prompt +// the recipient, and many silently ignore it. +func (b Builder) DispositionNotificationTo(name, addr string) Builder { + if addr == "" { + return b + } + if b.err != nil { + return b + } + if err := validateDisplayName(name); err != nil { + b.err = err + return b + } + // addr ends up inside mail.Address.String() and written unescaped into + // the Disposition-Notification-To header; validate it the same way as + // other header value inputs to prevent CR/LF header injection and + // visual-spoofing via Bidi / zero-width code points. + if err := validateHeaderValue(addr); err != nil { + b.err = err + return b + } + cp := b.copySlices() + cp.dispositionNotificationTo = append(cp.dispositionNotificationTo, mail.Address{Name: name, Address: addr}) + return cp +} + // Subject sets the Subject header. // Non-ASCII characters are automatically RFC 2047 B-encoded. // Returns an error builder if subject contains CR or LF. @@ -567,6 +602,21 @@ func (b Builder) AllowNoRecipients() Builder { return b } +// IsReadReceiptMail marks this message as a read-receipt response. +// When true, Build() writes the private header "X-Lark-Read-Receipt-Mail: 1", +// which data-access extracts into MailBodyExtra.IsReadReceiptMail on draft +// creation so the subsequent DraftSend applies the READ_RECEIPT_SENT label. +// +// The header is a Lark-internal signal; smtp-out-mail-out is expected to +// strip X-Lark-* private headers before external delivery. +func (b Builder) IsReadReceiptMail(v bool) Builder { + if b.err != nil { + return b + } + b.isReadReceiptMail = v + return b +} + // Header appends an extra header to the message. // Multiple calls with the same name result in multiple header lines. // Returns an error builder if name or value contains CR, LF, or (for names) ':'. @@ -659,6 +709,12 @@ func (b Builder) Build() ([]byte, error) { if len(b.replyTo) > 0 { writeHeader(&buf, "Reply-To", joinAddresses(b.replyTo)) } + if len(b.dispositionNotificationTo) > 0 { + writeHeader(&buf, "Disposition-Notification-To", joinAddresses(b.dispositionNotificationTo)) + } + if b.isReadReceiptMail { + writeHeader(&buf, "X-Lark-Read-Receipt-Mail", "1") + } if b.inReplyTo != "" { writeHeader(&buf, "In-Reply-To", "<"+b.inReplyTo+">") if b.lmsReplyToMessageID != "" { @@ -720,6 +776,7 @@ func (b Builder) copySlices() Builder { cp.cc = append([]mail.Address{}, b.cc...) cp.bcc = append([]mail.Address{}, b.bcc...) cp.replyTo = append([]mail.Address{}, b.replyTo...) + cp.dispositionNotificationTo = append([]mail.Address{}, b.dispositionNotificationTo...) cp.attachments = append([]attachment{}, b.attachments...) cp.inlines = append([]inline{}, b.inlines...) cp.extraHeaders = append([][2]string{}, b.extraHeaders...) diff --git a/shortcuts/mail/emlbuilder/builder_test.go b/shortcuts/mail/emlbuilder/builder_test.go index 652f8de5e..a26c94b0a 100644 --- a/shortcuts/mail/emlbuilder/builder_test.go +++ b/shortcuts/mail/emlbuilder/builder_test.go @@ -39,6 +39,7 @@ func headerValue(eml, name string) string { // ── validation ──────────────────────────────────────────────────────────────── +// TestBuild_MissingFrom verifies build missing from. func TestBuild_MissingFrom(t *testing.T) { _, err := New().To("", "bob@example.com").Subject("hi").Build() if err == nil || !strings.Contains(err.Error(), "From") { @@ -46,6 +47,7 @@ func TestBuild_MissingFrom(t *testing.T) { } } +// TestBuild_MissingRecipient verifies build missing recipient. func TestBuild_MissingRecipient(t *testing.T) { _, err := New().From("", "alice@example.com").Subject("hi").Build() if err == nil || !strings.Contains(err.Error(), "recipient") { @@ -55,6 +57,7 @@ func TestBuild_MissingRecipient(t *testing.T) { // ── single text/plain ───────────────────────────────────────────────────────── +// TestBuild_SingleTextPlain_ASCII verifies build single text plain ASCII. func TestBuild_SingleTextPlain_ASCII(t *testing.T) { raw, err := New(). From("Alice", "alice@example.com"). @@ -99,6 +102,7 @@ func TestBuild_SingleTextPlain_ASCII(t *testing.T) { } } +// TestBuild_SingleTextPlain_NonASCII verifies build single text plain non ASCII. func TestBuild_SingleTextPlain_NonASCII(t *testing.T) { raw, err := New(). From("", "alice@example.com"). @@ -141,6 +145,7 @@ func TestBuild_SingleTextPlain_NonASCII(t *testing.T) { // ── multipart/alternative ───────────────────────────────────────────────────── +// TestBuild_MultipartAlternative verifies build multipart alternative. func TestBuild_MultipartAlternative(t *testing.T) { raw, err := New(). From("", "alice@example.com"). @@ -177,6 +182,7 @@ func TestBuild_MultipartAlternative(t *testing.T) { // ── multipart/mixed (with attachments) ─────────────────────────────────────── +// TestBuild_WithAttachment verifies build with attachment. func TestBuild_WithAttachment(t *testing.T) { attContent := []byte("PDF content here") raw, err := New(). @@ -209,6 +215,7 @@ func TestBuild_WithAttachment(t *testing.T) { // ── reply threading headers ─────────────────────────────────────────────────── +// TestBuild_ReplyHeaders verifies build reply headers. func TestBuild_ReplyHeaders(t *testing.T) { raw, err := New(). From("", "alice@example.com"). @@ -235,6 +242,7 @@ func TestBuild_ReplyHeaders(t *testing.T) { } } +// TestBuild_LMSReplyToMessageID verifies build LMS reply to message ID. func TestBuild_LMSReplyToMessageID(t *testing.T) { raw, err := New(). From("", "alice@example.com"). @@ -256,6 +264,7 @@ func TestBuild_LMSReplyToMessageID(t *testing.T) { } } +// TestBuild_LMSReplyToMessageID_NotWrittenWithoutInReplyTo verifies build LMS reply to message ID not written without in reply to. func TestBuild_LMSReplyToMessageID_NotWrittenWithoutInReplyTo(t *testing.T) { raw, err := New(). From("", "alice@example.com"). @@ -276,8 +285,235 @@ func TestBuild_LMSReplyToMessageID_NotWrittenWithoutInReplyTo(t *testing.T) { } } +// ── Disposition-Notification-To (read receipt) ─────────────────────────────── + +// TestBuild_DispositionNotificationTo verifies build disposition notification to. +func TestBuild_DispositionNotificationTo(t *testing.T) { + raw, err := New(). + From("Alice", "alice@example.com"). + To("", "bob@example.com"). + Subject("hi"). + Date(fixedDate). + MessageID("dnt@x"). + DispositionNotificationTo("Alice", "alice@example.com"). + TextBody([]byte("please ack")). + Build() + if err != nil { + t.Fatal(err) + } + got := headerValue(string(raw), "Disposition-Notification-To") + want := `"Alice" ` + if got != want { + t.Errorf("Disposition-Notification-To: got %q, want %q", got, want) + } +} + +// TestBuild_DispositionNotificationTo_MultipleAddresses verifies build disposition notification to multiple addresses. +func TestBuild_DispositionNotificationTo_MultipleAddresses(t *testing.T) { + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("hi"). + Date(fixedDate). + MessageID("dnt-multi@x"). + DispositionNotificationTo("", "alice@example.com"). + DispositionNotificationTo("", "carol@example.com"). + TextBody([]byte("body")). + Build() + if err != nil { + t.Fatal(err) + } + got := headerValue(string(raw), "Disposition-Notification-To") + want := ", " + if got != want { + t.Errorf("Disposition-Notification-To: got %q, want %q", got, want) + } +} + +// TestBuild_DispositionNotificationTo_NotWrittenWhenUnset verifies build disposition notification to not written when unset. +func TestBuild_DispositionNotificationTo_NotWrittenWhenUnset(t *testing.T) { + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("hi"). + Date(fixedDate). + MessageID("no-dnt@x"). + TextBody([]byte("body")). + Build() + if err != nil { + t.Fatal(err) + } + if got := headerValue(string(raw), "Disposition-Notification-To"); got != "" { + t.Errorf("Disposition-Notification-To should be absent when unset, got %q", got) + } +} + +// TestBuild_DispositionNotificationTo_EmptyAddressIgnored verifies build disposition notification to empty address ignored. +func TestBuild_DispositionNotificationTo_EmptyAddressIgnored(t *testing.T) { + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("hi"). + Date(fixedDate). + MessageID("empty-dnt@x"). + DispositionNotificationTo("", ""). + TextBody([]byte("body")). + Build() + if err != nil { + t.Fatal(err) + } + if got := headerValue(string(raw), "Disposition-Notification-To"); got != "" { + t.Errorf("empty address should be ignored; got header %q", got) + } +} + +// TestBuild_DispositionNotificationTo_CRLFRejected verifies build disposition notification to CR LF rejected. +func TestBuild_DispositionNotificationTo_CRLFRejected(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("hi"). + Date(fixedDate). + DispositionNotificationTo("Alice\r\nBcc: evil@evil.com", "alice@example.com"). + TextBody([]byte("body")). + Build() + if err == nil || !strings.Contains(err.Error(), "display name") { + t.Fatalf("expected display-name CRLF error, got %v", err) + } +} + +// TestBuild_DispositionNotificationTo_AddrCRLFRejected verifies build disposition notification to addr CR LF rejected. +func TestBuild_DispositionNotificationTo_AddrCRLFRejected(t *testing.T) { + // Injection via the address (not just the display name) must be blocked. + // A plain mail.Address.String() would emit "" + // unchanged, allowing the attacker to inject new headers. + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("hi"). + Date(fixedDate). + DispositionNotificationTo("Alice", "alice@example.com\r\nX-Injected: pwned"). + TextBody([]byte("body")). + Build() + if err == nil || !strings.Contains(err.Error(), "control character") { + t.Fatalf("expected addr CRLF error, got %v", err) + } +} + +// TestBuild_DispositionNotificationTo_AddrBidiRejected verifies build disposition notification to addr bidi rejected. +func TestBuild_DispositionNotificationTo_AddrBidiRejected(t *testing.T) { + // Bidi overrides (U+202E RLO) enable visual spoofing (e.g. "gmail" + RLO + "com.evil.com" + // renders as gmail.com at the tail); they must be blocked in the addr + // too, not only in header names / display names. + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("hi"). + Date(fixedDate). + DispositionNotificationTo("Alice", "alice@gma\u202eil.com"). + TextBody([]byte("body")). + Build() + if err == nil || !strings.Contains(err.Error(), "dangerous Unicode") { + t.Fatalf("expected addr dangerous-Unicode error, got %v", err) + } +} + +// ── X-Lark-Read-Receipt-Mail (read receipt response marker) ────────────────── + +// TestBuild_IsReadReceiptMail_True verifies build is read receipt mail true. +func TestBuild_IsReadReceiptMail_True(t *testing.T) { + raw, err := New(). + From("", "bob@example.com"). + To("", "alice@example.com"). + Subject("已读回执:hi"). + Date(fixedDate). + MessageID("irrm@x"). + IsReadReceiptMail(true). + TextBody([]byte("read")). + Build() + if err != nil { + t.Fatal(err) + } + got := headerValue(string(raw), "X-Lark-Read-Receipt-Mail") + if got != "1" { + t.Errorf("X-Lark-Read-Receipt-Mail: got %q, want 1", got) + } +} + +// TestBuild_IsReadReceiptMail_DefaultAbsent verifies build is read receipt mail default absent. +func TestBuild_IsReadReceiptMail_DefaultAbsent(t *testing.T) { + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("hi"). + Date(fixedDate). + MessageID("no-irrm@x"). + TextBody([]byte("body")). + Build() + if err != nil { + t.Fatal(err) + } + if got := headerValue(string(raw), "X-Lark-Read-Receipt-Mail"); got != "" { + t.Errorf("X-Lark-Read-Receipt-Mail should be absent by default, got %q", got) + } +} + +// TestBuild_IsReadReceiptMail_ExplicitFalse verifies build is read receipt mail explicit false. +func TestBuild_IsReadReceiptMail_ExplicitFalse(t *testing.T) { + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("hi"). + Date(fixedDate). + MessageID("irrm-false@x"). + IsReadReceiptMail(false). + TextBody([]byte("body")). + Build() + if err != nil { + t.Fatal(err) + } + if got := headerValue(string(raw), "X-Lark-Read-Receipt-Mail"); got != "" { + t.Errorf("X-Lark-Read-Receipt-Mail should be absent when set false, got %q", got) + } +} + +// TestBuild_DispositionNotificationTo_PreservesPriorError verifies that once +// the Builder carries an error from a prior setter, DispositionNotificationTo +// short-circuits and does NOT clobber the existing error with a nil. +func TestBuild_DispositionNotificationTo_PreservesPriorError(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("bad\r\nheader"). // injects err + DispositionNotificationTo("Alice", "alice@example.com"). + Date(fixedDate). + TextBody([]byte("body")). + Build() + if err == nil || !strings.Contains(err.Error(), "control character") { + t.Fatalf("expected original Subject CRLF error to survive DispositionNotificationTo, got %v", err) + } +} + +// TestBuild_IsReadReceiptMail_PreservesPriorError verifies that once the +// Builder carries an error from a prior setter, IsReadReceiptMail short- +// circuits and does NOT clobber the existing error with a nil. +func TestBuild_IsReadReceiptMail_PreservesPriorError(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("bad\r\nheader"). // injects err + IsReadReceiptMail(true). + Date(fixedDate). + TextBody([]byte("body")). + Build() + if err == nil || !strings.Contains(err.Error(), "control character") { + t.Fatalf("expected original Subject CRLF error to survive IsReadReceiptMail, got %v", err) + } +} + // ── CC / BCC ────────────────────────────────────────────────────────────────── +// TestBuild_CCBCC verifies build c c b c c. func TestBuild_CCBCC(t *testing.T) { raw, err := New(). From("", "alice@example.com"). @@ -308,6 +544,7 @@ func TestBuild_CCBCC(t *testing.T) { } } +// TestAllRecipients verifies all recipients. func TestAllRecipients(t *testing.T) { b := New(). From("", "alice@example.com"). @@ -322,6 +559,7 @@ func TestAllRecipients(t *testing.T) { // ── BuildBase64URL ──────────────────────────────────────────────────────────── +// TestBuildBase64URL verifies build base64 URL. func TestBuildBase64URL(t *testing.T) { encoded, err := New(). From("", "alice@example.com"). @@ -355,6 +593,7 @@ func TestBuildBase64URL(t *testing.T) { // ── immutability ────────────────────────────────────────────────────────────── +// TestBuilder_Immutability verifies builder immutability. func TestBuilder_Immutability(t *testing.T) { base := New().From("", "alice@example.com").Subject("base") b1 := base.To("", "bob@example.com") @@ -374,6 +613,7 @@ func TestBuilder_Immutability(t *testing.T) { // ── ToAddrs / CCAddrs ───────────────────────────────────────────────────────── +// TestBuild_ToAddrs verifies build to addrs. func TestBuild_ToAddrs(t *testing.T) { addrs := []mail.Address{ {Name: "Bob", Address: "bob@example.com"}, @@ -398,6 +638,7 @@ func TestBuild_ToAddrs(t *testing.T) { // ── CalendarBody ────────────────────────────────────────────────────────────── +// TestBuild_CalendarBody_Single verifies build calendar body single. func TestBuild_CalendarBody_Single(t *testing.T) { calData := []byte("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR") raw, err := New(). @@ -421,6 +662,7 @@ func TestBuild_CalendarBody_Single(t *testing.T) { } } +// TestBuild_CalendarWithText verifies build calendar with text. func TestBuild_CalendarWithText(t *testing.T) { raw, err := New(). From("", "alice@example.com"). @@ -449,6 +691,7 @@ func TestBuild_CalendarWithText(t *testing.T) { // ── AddInline / multipart/related ──────────────────────────────────────────── +// TestBuild_WithInline verifies build with inline. func TestBuild_WithInline(t *testing.T) { imgBytes := []byte("\x89PNG\r\n\x1a\n") // minimal PNG magic bytes raw, err := New(). @@ -488,6 +731,7 @@ func TestBuild_WithInline(t *testing.T) { } } +// TestBuild_WithOtherPart verifies build with other part. func TestBuild_WithOtherPart(t *testing.T) { calData := []byte("BEGIN:VCALENDAR\r\nEND:VCALENDAR") raw, err := New(). @@ -516,6 +760,7 @@ func TestBuild_WithOtherPart(t *testing.T) { } } +// TestBuild_FoldBodyLines_Base64 verifies build fold body lines base64. func TestBuild_FoldBodyLines_Base64(t *testing.T) { body := strings.Repeat("你", 120) raw, err := New(). @@ -541,6 +786,7 @@ func TestBuild_FoldBodyLines_Base64(t *testing.T) { } } +// TestBuild_FoldBodyLines_7bit verifies build fold body lines 7bit. func TestBuild_FoldBodyLines_7bit(t *testing.T) { body := strings.Repeat("A", 200) raw, err := New(). @@ -567,6 +813,7 @@ func TestBuild_FoldBodyLines_7bit(t *testing.T) { } } +// TestBuild_InlineAndAttachment verifies build inline and attachment. func TestBuild_InlineAndAttachment(t *testing.T) { imgBytes := []byte("fake-png") pdfBytes := []byte("fake-pdf") @@ -620,6 +867,7 @@ func TestBuild_InlineContentIDNormalisation(t *testing.T) { // ── extra Header ───────────────────────────────────────────────────────────── +// TestBuild_ExtraHeader verifies build extra header. func TestBuild_ExtraHeader(t *testing.T) { raw, err := New(). From("", "alice@example.com"). @@ -640,6 +888,7 @@ func TestBuild_ExtraHeader(t *testing.T) { // ── CRLF / header-injection guards ─────────────────────────────────────────── +// TestSubjectCRLFRejected verifies subject CR LF rejected. func TestSubjectCRLFRejected(t *testing.T) { for _, inj := range []string{"legit\r\nBcc: evil@evil.com", "legit\nBcc: evil@evil.com", "legit\rBcc: evil@evil.com"} { _, err := New(). @@ -656,6 +905,7 @@ func TestSubjectCRLFRejected(t *testing.T) { } } +// TestMessageIDCRLFRejected verifies message ID CR LF rejected. func TestMessageIDCRLFRejected(t *testing.T) { _, err := New(). From("", "alice@example.com"). @@ -670,6 +920,7 @@ func TestMessageIDCRLFRejected(t *testing.T) { } } +// TestInReplyToCRLFRejected verifies in reply to CR LF rejected. func TestInReplyToCRLFRejected(t *testing.T) { _, err := New(). From("", "alice@example.com"). @@ -685,6 +936,7 @@ func TestInReplyToCRLFRejected(t *testing.T) { } } +// TestReferencesCRLFRejected verifies references CR LF rejected. func TestReferencesCRLFRejected(t *testing.T) { _, err := New(). From("", "alice@example.com"). @@ -700,6 +952,7 @@ func TestReferencesCRLFRejected(t *testing.T) { } } +// TestHeaderNameColonRejected verifies header name colon rejected. func TestHeaderNameColonRejected(t *testing.T) { _, err := New(). From("", "alice@example.com"). @@ -715,6 +968,7 @@ func TestHeaderNameColonRejected(t *testing.T) { } } +// TestHeaderNameCRLFRejected verifies header name CR LF rejected. func TestHeaderNameCRLFRejected(t *testing.T) { _, err := New(). From("", "alice@example.com"). @@ -730,6 +984,7 @@ func TestHeaderNameCRLFRejected(t *testing.T) { } } +// TestHeaderValueCRLFRejected verifies header value CR LF rejected. func TestHeaderValueCRLFRejected(t *testing.T) { _, err := New(). From("", "alice@example.com"). @@ -745,6 +1000,7 @@ func TestHeaderValueCRLFRejected(t *testing.T) { } } +// TestFromDisplayNameCRLFRejected verifies from display name CR LF rejected. func TestFromDisplayNameCRLFRejected(t *testing.T) { _, err := New(). From("Alice\r\nBcc: evil@evil.com", "alice@example.com"). @@ -759,6 +1015,7 @@ func TestFromDisplayNameCRLFRejected(t *testing.T) { } } +// TestToDisplayNameCRLFRejected verifies to display name CR LF rejected. func TestToDisplayNameCRLFRejected(t *testing.T) { _, err := New(). From("", "alice@example.com"). @@ -773,6 +1030,7 @@ func TestToDisplayNameCRLFRejected(t *testing.T) { } } +// TestAddAttachmentContentTypeCRLFRejected verifies add attachment content type CR LF rejected. func TestAddAttachmentContentTypeCRLFRejected(t *testing.T) { _, err := New(). From("", "alice@example.com"). @@ -788,6 +1046,7 @@ func TestAddAttachmentContentTypeCRLFRejected(t *testing.T) { } } +// TestAddAttachmentFileNameCRLFRejected verifies add attachment file name CR LF rejected. func TestAddAttachmentFileNameCRLFRejected(t *testing.T) { _, err := New(). From("", "alice@example.com"). @@ -803,6 +1062,7 @@ func TestAddAttachmentFileNameCRLFRejected(t *testing.T) { } } +// TestAddInlineContentTypeCRLFRejected verifies add inline content type CR LF rejected. func TestAddInlineContentTypeCRLFRejected(t *testing.T) { _, err := New(). From("", "alice@example.com"). @@ -818,6 +1078,7 @@ func TestAddInlineContentTypeCRLFRejected(t *testing.T) { } } +// TestAddInlineContentIDCRLFRejected verifies add inline content ID CR LF rejected. func TestAddInlineContentIDCRLFRejected(t *testing.T) { _, err := New(). From("", "alice@example.com"). @@ -833,6 +1094,7 @@ func TestAddInlineContentIDCRLFRejected(t *testing.T) { } } +// TestAddInlineFileNameCRLFRejected verifies add inline file name CR LF rejected. func TestAddInlineFileNameCRLFRejected(t *testing.T) { _, err := New(). From("", "alice@example.com"). @@ -848,6 +1110,7 @@ func TestAddInlineFileNameCRLFRejected(t *testing.T) { } } +// TestAddOtherPartFileNameCRLFRejected verifies add other part file name CR LF rejected. func TestAddOtherPartFileNameCRLFRejected(t *testing.T) { _, err := New(). From("", "alice@example.com"). @@ -863,6 +1126,7 @@ func TestAddOtherPartFileNameCRLFRejected(t *testing.T) { } } +// TestAddInlineContentIDControlCharRejected verifies add inline content ID control char rejected. func TestAddInlineContentIDControlCharRejected(t *testing.T) { _, err := New(). From("", "alice@example.com"). @@ -878,6 +1142,7 @@ func TestAddInlineContentIDControlCharRejected(t *testing.T) { } } +// TestAddOtherPartContentIDControlCharRejected verifies add other part content ID control char rejected. func TestAddOtherPartContentIDControlCharRejected(t *testing.T) { _, err := New(). From("", "alice@example.com"). @@ -893,6 +1158,7 @@ func TestAddOtherPartContentIDControlCharRejected(t *testing.T) { } } +// TestHeaderValueControlCharRejected verifies header value control char rejected. func TestHeaderValueControlCharRejected(t *testing.T) { cases := []struct { name string @@ -923,6 +1189,7 @@ func TestHeaderValueControlCharRejected(t *testing.T) { } } +// TestHeaderValueDangerousUnicodeRejected verifies header value dangerous unicode rejected. func TestHeaderValueDangerousUnicodeRejected(t *testing.T) { cases := []struct { name string @@ -954,6 +1221,7 @@ func TestHeaderValueDangerousUnicodeRejected(t *testing.T) { // ── blocked extension via AddFileAttachment ─────────────────────────────────── +// TestAddFileAttachmentBlockedExtension verifies add file attachment blocked extension. func TestAddFileAttachmentBlockedExtension(t *testing.T) { dir := t.TempDir() orig, _ := os.Getwd() @@ -985,6 +1253,7 @@ func TestAddFileAttachmentBlockedExtension(t *testing.T) { } } +// TestAddFileInlineBlockedFormat verifies add file inline blocked format. func TestAddFileInlineBlockedFormat(t *testing.T) { dir := t.TempDir() orig, _ := os.Getwd() @@ -1015,6 +1284,7 @@ func TestAddFileInlineBlockedFormat(t *testing.T) { } } +// TestAddFileInlineAllowedFormat verifies add file inline allowed format. func TestAddFileInlineAllowedFormat(t *testing.T) { dir := t.TempDir() orig, _ := os.Getwd() @@ -1044,6 +1314,7 @@ func TestAddFileInlineAllowedFormat(t *testing.T) { } } +// TestAddFileAttachmentAllowedExtension verifies add file attachment allowed extension. func TestAddFileAttachmentAllowedExtension(t *testing.T) { dir := t.TempDir() orig, _ := os.Getwd() @@ -1072,6 +1343,7 @@ func TestAddFileAttachmentAllowedExtension(t *testing.T) { } } +// TestHeaderValueTabAllowed verifies header value tab allowed. func TestHeaderValueTabAllowed(t *testing.T) { // Tab (\t) is valid in folded header values per RFC 5322 _, err := New(). diff --git a/shortcuts/mail/helpers.go b/shortcuts/mail/helpers.go index 03ee79b11..876e77bb6 100644 --- a/shortcuts/mail/helpers.go +++ b/shortcuts/mail/helpers.go @@ -54,6 +54,89 @@ func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID sanitizeForTerminal(mailboxID), sanitizeForTerminal(originalMessageID)) } +// hintReadReceiptRequest prints a stderr tip when a message that the caller +// just read requested a read receipt (carries the READ_RECEIPT_REQUEST label). +// The tip is emitted at CLI level so any caller — agents that read SKILL.md +// and those that don't — sees the prompt. Privacy is sensitive here: sending +// a receipt tells the remote party "I have read your message", so the tip +// explicitly instructs the caller to ask the user before responding. +// +// All four interpolated values (fromEmail, subject, mailboxID, messageID) +// come from untrusted email content or raw API input; they are run through +// sanitizeForSingleLine (for fromEmail) / %q (for subject) / shellQuoteForHint +// (for the command-line values) so a crafted "From: x@y.com\ntip: reply +// harmless-looking-addr@attacker..." can't forge extra tip lines, and values +// with shell metacharacters survive copy-paste intact. +func hintReadReceiptRequest(runtime *common.RuntimeContext, mailboxID, messageID, fromEmail, subject string) { + fmt.Fprintf(runtime.IO().ErrOut, + "tip: sender requested a read receipt (READ_RECEIPT_REQUEST).\n"+ + " - do NOT auto-act; ask the user first (from=%s, subject=%q)\n"+ + " - if the user agrees to confirm they have read it:\n"+ + " lark-cli mail +send-receipt --mailbox '%s' --message-id '%s' --yes\n"+ + " - if the user wants to dismiss the banner without sending a receipt:\n"+ + " lark-cli mail +decline-receipt --mailbox '%s' --message-id '%s'\n", + sanitizeForSingleLine(fromEmail), sanitizeForSingleLine(subject), + shellQuoteForHint(mailboxID), shellQuoteForHint(messageID), + shellQuoteForHint(mailboxID), shellQuoteForHint(messageID)) +} + +// shellQuoteForHint returns s sanitized for single-line terminal output AND +// safe to embed inside single-quoted shell arguments: each single quote in +// the payload is rewritten as '\” (close-quote, escaped quote, re-open +// quote). Callers are expected to wrap the result in outer single quotes, +// as hintReadReceiptRequest does in its format string. Use this only for +// user-copy-paste hints, not for building commands that the CLI itself +// executes. +func shellQuoteForHint(s string) string { + return strings.ReplaceAll(sanitizeForSingleLine(s), "'", `'\''`) +} + +// requireSenderForRequestReceipt returns a validation error when --request- +// receipt is set but no sender address could be resolved. The Disposition- +// Notification-To header can only be addressed to a known sender — silently +// dropping the header when senderEmail is empty would mislead the caller into +// believing a receipt was requested when it wasn't. Intended to be called +// from a shortcut's Execute right after the sender address has been resolved. +// +// The error wording is deliberately generic about recovery: compose shortcuts +// (+send, +reply, +reply-all, +forward, +draft-create) can accept --from to +// set the sender, but +draft-edit's --from names the mailbox that owns the +// draft, not the DNT address — for that case the recovery is to make sure +// the draft already has a valid From header. Pointing at --from unconditionally +// would send +draft-edit users to the wrong flag. +func requireSenderForRequestReceipt(runtime *common.RuntimeContext, senderEmail string) error { + if !runtime.Bool("request-receipt") { + return nil + } + if strings.TrimSpace(senderEmail) == "" { + return output.ErrValidation( + "--request-receipt requires a resolvable sender address; specify a sender address where supported, or ensure the draft has a From address") + } + return nil +} + +// validateHeaderAddress rejects addresses that cannot be safely embedded in +// a MIME header value: anything with a control character (CR / LF / DEL / +// other C0) or a dangerous Unicode code point (BiDi / zero-width / line +// separator) would let a malicious From header inject additional headers or +// visually spoof a recipient. +// +// This mirrors emlbuilder.validateHeaderValue and exists separately for +// call sites that build header patches directly (e.g. mail_draft_edit +// synthesizing a set_header op for Disposition-Notification-To) without +// going through the builder. +func validateHeaderAddress(addr string) error { + for _, r := range addr { + if r != '\t' && (r < 0x20 || r == 0x7f) { + return fmt.Errorf("address contains control character: %q", addr) + } + if common.IsDangerousUnicode(r) { + return fmt.Errorf("address contains dangerous Unicode code point: %q", addr) + } + } + return nil +} + // messageOutputSchema returns a JSON description of +message / +messages / +thread output fields. // Used by --print-output-schema to let callers discover field names without reading skill docs. func printMessageOutputSchema(runtime *common.RuntimeContext) { @@ -245,6 +328,9 @@ func fetchMailboxPrimaryEmail(runtime *common.RuntimeContext, mailboxID string) return "", fmt.Errorf("profile API returned no primary_email_address") } +// extractPrimaryEmail returns the user's primary email address from a +// mailbox profile API response (key "primary_email_address"), or "" when the +// field is missing or empty. func extractPrimaryEmail(data map[string]interface{}) string { if email, ok := data["primary_email_address"].(string); ok && strings.TrimSpace(email) != "" { return strings.TrimSpace(email) @@ -375,17 +461,25 @@ func resolveSystemLabel(input string) (string, bool) { return "", false } +// folderInfo is the normalized local representation of a mailbox folder, +// used by the folder-resolution helpers. type folderInfo struct { ID string Name string ParentFolderID string } +// labelInfo is the normalized local representation of a mailbox label, +// used by the label-resolution helpers. type labelInfo struct { ID string Name string } +// resolveFolderID accepts either a folder ID or a folder name and returns +// the canonical folder ID. System folder aliases (INBOX, SENT, etc.) are +// resolved locally without an API call; custom folders are looked up via +// the mailbox folders endpoint. func resolveFolderID(runtime *common.RuntimeContext, mailboxID, input string) (string, error) { value := strings.TrimSpace(input) if value == "" { @@ -401,6 +495,9 @@ func resolveFolderID(runtime *common.RuntimeContext, mailboxID, input string) (s return resolveByID("folder", value, mailboxID, folders, func(item folderInfo) string { return item.ID }) } +// resolveFolderName accepts either a folder ID or a folder name and returns +// the human-readable folder name. Used for output rendering where the user +// wants to see the name they originally chose, not the opaque ID. func resolveFolderName(runtime *common.RuntimeContext, mailboxID, input string) (string, error) { value := strings.TrimSpace(input) if value == "" { @@ -419,6 +516,9 @@ func resolveFolderName(runtime *common.RuntimeContext, mailboxID, input string) ) } +// resolveLabelID accepts either a label ID or a label name and returns the +// canonical label ID. System label aliases (UNREAD, STARRED, etc.) resolve +// locally; custom labels are looked up via the mailbox labels endpoint. func resolveLabelID(runtime *common.RuntimeContext, mailboxID, input string) (string, error) { value := strings.TrimSpace(input) if value == "" { @@ -434,6 +534,8 @@ func resolveLabelID(runtime *common.RuntimeContext, mailboxID, input string) (st return resolveByID("label", value, mailboxID, labels, func(item labelInfo) string { return item.ID }) } +// resolveLabelName accepts either a label ID or a label name and returns +// the human-readable label name (mirror of resolveFolderName for labels). func resolveLabelName(runtime *common.RuntimeContext, mailboxID, input string) (string, error) { value := strings.TrimSpace(input) if value == "" { @@ -459,6 +561,9 @@ func resolveLabelName(runtime *common.RuntimeContext, mailboxID, input string) ( return id, nil } +// resolveFolderQueryName resolves a folder ID or name to the API-side query +// value (search-style folder syntax). Used by +triage / search to translate +// user-facing folder identifiers into API-acceptable strings. func resolveFolderQueryName(runtime *common.RuntimeContext, mailboxID, input string) (string, error) { value := strings.TrimSpace(input) if value == "" { @@ -484,6 +589,8 @@ func resolveFolderQueryName(runtime *common.RuntimeContext, mailboxID, input str return folderSearchPath(name, value, folders), nil } +// resolveFolderQueryNameFromID resolves a folder ID (already known) to its +// API-side query value, skipping the by-name lookup path. func resolveFolderQueryNameFromID(runtime *common.RuntimeContext, mailboxID, input string) (string, error) { value := strings.TrimSpace(input) if value == "" { @@ -527,6 +634,8 @@ func folderSearchPath(resolvedName, input string, folders []folderInfo) string { return resolvedName } +// resolveLabelQueryName mirrors resolveFolderQueryName for labels: returns +// the search-style label query value from a label ID or name. func resolveLabelQueryName(runtime *common.RuntimeContext, mailboxID, input string) (string, error) { value := strings.TrimSpace(input) if value == "" { @@ -554,6 +663,8 @@ func resolveLabelQueryName(runtime *common.RuntimeContext, mailboxID, input stri return name, nil } +// resolveLabelQueryNameFromID mirrors resolveFolderQueryNameFromID for +// labels: shortcut path when the label ID is already known. func resolveLabelQueryNameFromID(runtime *common.RuntimeContext, mailboxID, input string) (string, error) { value := strings.TrimSpace(input) if value == "" { @@ -600,6 +711,9 @@ func matchLabelSuffixID(input string, labels []labelInfo) string { return "" } +// resolveFolderNames resolves a list of folder IDs / names to their +// human-readable names. Stops at the first error; partial results are not +// returned. func resolveFolderNames(runtime *common.RuntimeContext, mailboxID string, values []string) ([]string, error) { resolved := make([]string, 0, len(values)) seen := make(map[string]bool) @@ -636,6 +750,7 @@ func resolveFolderNames(runtime *common.RuntimeContext, mailboxID string, values return resolved, nil } +// resolveLabelNames is the label-side counterpart of resolveFolderNames. func resolveLabelNames(runtime *common.RuntimeContext, mailboxID string, values []string) ([]string, error) { resolved := make([]string, 0, len(values)) seen := make(map[string]bool) @@ -672,6 +787,9 @@ func resolveLabelNames(runtime *common.RuntimeContext, mailboxID string, values return resolved, nil } +// resolveFolderSystemAliasOrID returns the canonical system folder ID for +// the given input (an alias like "INBOX" or an ID). Returns (id, true) when +// recognised; ("", false) for non-system inputs. func resolveFolderSystemAliasOrID(input string) (string, bool) { if id, ok := folderAliasToSystemID[strings.ToLower(strings.TrimSpace(input))]; ok { return id, true @@ -679,10 +797,16 @@ func resolveFolderSystemAliasOrID(input string) (string, bool) { return normalizeSystemID(input, folderSystemIDs) } +// resolveLabelSystemID is the label counterpart of +// resolveFolderSystemAliasOrID: returns the system label ID when input +// matches a known system label. func resolveLabelSystemID(input string) (string, bool) { return resolveSystemLabel(input) } +// normalizeSystemID checks whether input is a known system identifier +// listed in systemIDs and returns the canonical form. Returns ("", false) +// when input does not match any system ID. func normalizeSystemID(input string, systemIDs map[string]bool) (string, bool) { canonical := strings.ToUpper(strings.TrimSpace(input)) if canonical == "" { @@ -694,6 +818,8 @@ func normalizeSystemID(input string, systemIDs map[string]bool) (string, bool) { return "", false } +// addUniqueID appends id to *dst when id is non-empty and not already in +// the seen set. Both dst and seen are updated in place. func addUniqueID(dst *[]string, seen map[string]bool, id string) { if id == "" || seen[id] { return @@ -702,6 +828,9 @@ func addUniqueID(dst *[]string, seen map[string]bool, id string) { *dst = append(*dst, id) } +// listMailboxFolders fetches every custom folder for a mailbox via the +// folders.list API. System folders are NOT included; callers that need them +// should fall back to local resolution via resolveFolderSystemAliasOrID. func listMailboxFolders(runtime *common.RuntimeContext, mailboxID string) ([]folderInfo, error) { if err := validateFolderReadScope(runtime); err != nil { return nil, err @@ -726,6 +855,7 @@ func listMailboxFolders(runtime *common.RuntimeContext, mailboxID string) ([]fol return folders, nil } +// listMailboxLabels is the label counterpart of listMailboxFolders. func listMailboxLabels(runtime *common.RuntimeContext, mailboxID string) ([]labelInfo, error) { if err := validateLabelReadScope(runtime); err != nil { return nil, err @@ -750,6 +880,10 @@ func listMailboxLabels(runtime *common.RuntimeContext, mailboxID string) ([]labe return labels, nil } +// resolveByID looks up input as an ID in items, returning input itself when +// found. kind ("folder" / "label") and mailboxID are used to construct the +// not-found hint. Generic over T so the same logic serves both folder and +// label tables. func resolveByID[T any](kind, input, mailboxID string, items []T, idFn func(T) string) (string, error) { value := strings.TrimSpace(input) if value == "" { @@ -763,6 +897,9 @@ func resolveByID[T any](kind, input, mailboxID string, items []T, idFn func(T) s return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID)) } +// resolveByName looks up input as a name in items and returns the matching +// ID. Errors out on duplicates so callers get a clear "ambiguous name" +// signal rather than silently picking one match. func resolveByName[T any](kind, input, mailboxID string, items []T, idFn func(T) string, nameFn func(T) string) (string, error) { value := strings.TrimSpace(input) if value == "" { @@ -800,6 +937,8 @@ func resolveByName[T any](kind, input, mailboxID string, items []T, idFn func(T) return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID)) } +// resolveNameValueByID is the inverse of resolveByID: it looks up an ID +// and returns the matching name, used by the *QueryName resolvers. func resolveNameValueByID[T any](kind, input, mailboxID string, items []T, idFn func(T) string, nameFn func(T) string) (string, error) { value := strings.TrimSpace(input) if value == "" { @@ -817,6 +956,10 @@ func resolveNameValueByID[T any](kind, input, mailboxID string, items []T, idFn return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID)) } +// resolveNameValueByNameAllowDuplicates is like resolveByName but tolerates +// duplicate names — returning the first match. Used in query-style contexts +// where ambiguity is acceptable because the API itself disambiguates server- +// side. func resolveNameValueByNameAllowDuplicates[T any](kind, input, mailboxID string, items []T, idFn func(T) string, nameFn func(T) string) (string, error) { value := strings.TrimSpace(input) if value == "" { @@ -838,6 +981,10 @@ func resolveNameValueByNameAllowDuplicates[T any](kind, input, mailboxID string, return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID)) } +// resolveLookupHint returns the CLI command a user should run to list +// valid IDs / names for the given lookup kind ("folder" / "label") and +// mailbox. Used in not-found error messages so callers see an immediate +// recovery path. func resolveLookupHint(kind, mailboxID string) string { if mailboxID == "" { mailboxID = "me" @@ -914,6 +1061,9 @@ func fetchFullMessages(runtime *common.RuntimeContext, mailboxID string, message return ordered, missing, nil } +// messageGetFormat maps an html flag to the server-side messages.get format +// value: "full" when HTML body is wanted, "plain_text_full" otherwise (the +// server then omits body_html, saving bandwidth). func messageGetFormat(html bool) string { if html { return "full" @@ -935,6 +1085,9 @@ func extractAttachmentIDs(msg map[string]interface{}) []string { return ids } +// warningEntry is a single structured warning emitted alongside primary +// output (e.g. when an attachment fails to download but the message itself +// is still returned). Serialized via the shared "warnings" output channel. type warningEntry struct { Code string `json:"code"` Level string `json:"level"` @@ -944,6 +1097,9 @@ type warningEntry struct { Detail string `json:"detail"` } +// mailAddressOutput is the JSON-serialized address form used in public +// output (name + email). Distinct from mailAddressPair which is the +// internal value type used during body composition. type mailAddressOutput struct { Email string `json:"email"` Name string `json:"name"` @@ -955,6 +1111,9 @@ type mailAddressPair struct { Name string } +// toAddressPairList converts JSON-output addresses (mailAddressOutput) to +// the internal mailAddressPair type used during body composition, +// dropping entries without an email address. func toAddressPairList(raw []mailAddressOutput) []mailAddressPair { out := make([]mailAddressPair, 0, len(raw)) for _, addr := range raw { @@ -965,6 +1124,9 @@ func toAddressPairList(raw []mailAddressOutput) []mailAddressPair { return out } +// mailAttachmentOutput is the JSON form of a regular (non-inline) +// attachment: ID, filename, content type, attachment type code, and the +// time-limited download URL when requested. type mailAttachmentOutput struct { ID string `json:"id"` Filename string `json:"filename"` @@ -973,6 +1135,8 @@ type mailAttachmentOutput struct { DownloadURL string `json:"download_url,omitempty"` } +// mailImageOutput is the JSON form of a CID-referenced inline image in the +// HTML body. CID is required; DownloadURL is optional. type mailImageOutput struct { ID string `json:"id"` Filename string `json:"filename"` @@ -981,6 +1145,9 @@ type mailImageOutput struct { DownloadURL string `json:"download_url,omitempty"` } +// mailPublicAttachmentOutput is the unified attachment shape exposed on the +// public "attachments" field of message output — merges inline and regular +// attachments with an IsInline flag and optional CID. type mailPublicAttachmentOutput struct { ID string `json:"id"` Filename string `json:"filename"` @@ -990,6 +1157,9 @@ type mailPublicAttachmentOutput struct { CID string `json:"cid,omitempty"` } +// mailSecurityLevelOutput is the JSON form of the message's risk banner +// classification (external / phishing / similar). Present only when the +// backend flags the message; omitted on trusted messages. type mailSecurityLevelOutput struct { IsRisk bool `json:"is_risk"` RiskBannerLevel string `json:"risk_banner_level"` @@ -1047,6 +1217,9 @@ func fetchAttachmentURLs(runtime *common.RuntimeContext, mailboxID, messageID st return fetchAttachmentURLsWith(runtime, mailboxID, messageID, ids, callAPI, emitWarning) } +// fetchAttachmentURLsWith resolves time-limited download URLs for each +// attachment ID via the attachments.download_url API. Returns a per-ID URL +// map plus a list of warnings for IDs the backend declined to resolve. func fetchAttachmentURLsWith( runtime *common.RuntimeContext, mailboxID, messageID string, @@ -1120,10 +1293,16 @@ func fetchAttachmentURLsWith( return urlMap, warnings } +// rawMessageExcludedFields lists API response fields that must NOT be +// auto-passed through to the public output because they are replaced by a +// derived public shape (see buildPublicAttachments / derivedMessageFields). var rawMessageExcludedFields = map[string]struct{}{ "attachments": {}, } +// derivedMessageFields names the public output keys that are synthesized +// from the raw API response rather than copied through verbatim. Used by +// shouldExposeRawMessageField and by the output schema printed for agents. var derivedMessageFields = []string{ "draft_id", "body_plain_text", @@ -1175,6 +1354,9 @@ func buildMessageOutput(msg map[string]interface{}, html bool) map[string]interf return out } +// buildPublicAttachments returns the unified "attachments" list for +// message output, merging inline and regular attachments into a single +// shape with the IsInline flag set accordingly. func buildPublicAttachments(msg map[string]interface{}) []mailPublicAttachmentOutput { rawAtts, _ := msg["attachments"].([]interface{}) out := make([]mailPublicAttachmentOutput, 0, len(rawAtts)) @@ -1199,6 +1381,9 @@ func buildPublicAttachments(msg map[string]interface{}) []mailPublicAttachmentOu return out } +// derivedDraftID returns the draft identifier for a message that is +// itself a draft (message_state == draft). For non-draft messages returns +// "". messageID is used as fallback when the backend omits draft_id. func derivedDraftID(msg map[string]interface{}, messageID string) string { if draftID := strVal(msg["draft_id"]); draftID != "" { return draftID @@ -1315,6 +1500,8 @@ func buildMessageForCompose(msg map[string]interface{}, urlMap map[string]string return out } +// pickSafeMessageFields returns a shallow copy of msg containing only +// fields safe to expose in public output (per shouldExposeRawMessageField). func pickSafeMessageFields(msg map[string]interface{}) map[string]interface{} { out := make(map[string]interface{}, len(msg)) for key, value := range msg { @@ -1326,6 +1513,9 @@ func pickSafeMessageFields(msg map[string]interface{}) map[string]interface{} { return out } +// shouldExposeRawMessageField reports whether key from a raw message +// response is safe to pass through to public output (i.e. not a body field +// handled separately and not in rawMessageExcludedFields). func shouldExposeRawMessageField(key string) bool { if strings.HasPrefix(key, "body_") { return false @@ -1340,6 +1530,10 @@ func shouldExposeRawMessageField(key string) bool { // and downloading could cause OOM for very large files. const attachmentTypeLarge = 2 +// forwardSourceAttachment is the compose-side view of an attachment on the +// original message being forwarded. AttachmentType 1 means a normal +// attachment that will be downloaded and re-attached; type 2 (large) is +// represented as an in-body link instead. type forwardSourceAttachment struct { ID string Filename string @@ -1348,6 +1542,9 @@ type forwardSourceAttachment struct { DownloadURL string } +// inlineSourcePart is the compose-side view of a CID-referenced inline +// resource on the original message that will be re-embedded in the +// reply / forward. type inlineSourcePart struct { ID string Filename string @@ -1356,6 +1553,10 @@ type inlineSourcePart struct { DownloadURL string } +// composeSourceMessage bundles everything a reply / forward operation needs +// to know about the original message: the normalized originalMessage, the +// list of forward-able attachments, the list of inline parts to re-embed, +// and the set of attachment IDs whose download preflight failed. type composeSourceMessage struct { Original originalMessage ForwardAttachments []forwardSourceAttachment @@ -1424,6 +1625,9 @@ func validateInlineImageURLs(src composeSourceMessage) error { return nil } +// toOriginalMessageForCompose lifts the normalized message representation +// into the originalMessage value type used by +reply / +forward body +// builders. func toOriginalMessageForCompose(out normalizedMessageForCompose) originalMessage { fromEmail, fromName := out.From.Email, out.From.Name toList := toAddressEmailList(out.To) @@ -1478,6 +1682,8 @@ func toOriginalMessageForCompose(out normalizedMessageForCompose) originalMessag } } +// toForwardSourceAttachments extracts the forward-capable attachments from +// a normalized message (non-inline attachments, both regular and large). func toForwardSourceAttachments(out normalizedMessageForCompose) []forwardSourceAttachment { atts := make([]forwardSourceAttachment, 0, len(out.Attachments)) for _, att := range out.Attachments { @@ -1492,6 +1698,8 @@ func toForwardSourceAttachments(out normalizedMessageForCompose) []forwardSource return atts } +// toInlineSourceParts extracts the CID-referenced inline resources from a +// normalized message for re-embedding in a reply / forward. func toInlineSourceParts(out normalizedMessageForCompose) []inlineSourcePart { parts := make([]inlineSourcePart, 0, len(out.Images)) for _, img := range out.Images { @@ -1556,11 +1764,15 @@ func downloadAttachmentContent(runtime *common.RuntimeContext, downloadURL strin // --- internal helpers --- +// strVal returns v as a string when it is one, otherwise "". Used to +// safely extract string fields from decoded JSON maps. func strVal(v interface{}) string { s, _ := v.(string) return s } +// intVal returns v as an int, parsing string forms and coercing JSON +// float64 when needed. Returns 0 when v is nil or non-numeric. func intVal(v interface{}) int { switch n := v.(type) { case float64: @@ -1574,6 +1786,8 @@ func intVal(v interface{}) int { return 0 } +// decodeBase64URL returns the decoded bytes of a base64url-encoded string +// (either padded or raw). Returns "" on decode error. func decodeBase64URL(s string) string { if s == "" { return "" @@ -1604,7 +1818,10 @@ var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) // sanitizeForTerminal strips ANSI escape sequences, bare CR characters, and // dangerous Unicode code points (BiDi overrides, zero-width chars, etc.) to -// prevent terminal injection from untrusted email content. +// prevent terminal injection from untrusted email content. LF is preserved +// because legitimate multi-line content (body_text, body_html_summary) is +// printed through this helper; use sanitizeForSingleLine when the caller +// needs a single-line guarantee. func sanitizeForTerminal(s string) string { s = ansiEscapeRe.ReplaceAllString(s, "") var b strings.Builder @@ -1621,6 +1838,17 @@ func sanitizeForTerminal(s string) string { return b.String() } +// sanitizeForSingleLine is sanitizeForTerminal plus LF removal, for callers +// whose output must stay on one logical line — stderr hints, embedded +// command-line arguments, etc. A malicious From header or subject containing +// "\ntip: ..." can no longer forge extra lines in the prompt and trick a +// reader into thinking the CLI emitted them. +func sanitizeForSingleLine(s string) string { + return strings.ReplaceAll(sanitizeForTerminal(s), "\n", "") +} + +// toAddressObject converts a raw address field (map form) from the API +// response into mailAddressOutput. Returns zero value when v isn't a map. func toAddressObject(v interface{}) mailAddressOutput { if m, ok := v.(map[string]interface{}); ok { return mailAddressOutput{Email: strVal(m["mail_address"]), Name: strVal(m["name"])} @@ -1628,6 +1856,8 @@ func toAddressObject(v interface{}) mailAddressOutput { return mailAddressOutput{} } +// toAddressList converts a raw address-list field from the API response +// (array of maps) into []mailAddressOutput. func toAddressList(v interface{}) []mailAddressOutput { list, _ := v.([]interface{}) out := make([]mailAddressOutput, 0, len(list)) @@ -1637,6 +1867,8 @@ func toAddressList(v interface{}) []mailAddressOutput { return out } +// toAddressEmailList extracts just the email addresses from a list of +// mailAddressOutput, dropping entries with empty email. func toAddressEmailList(raw []mailAddressOutput) []string { out := make([]string, 0, len(raw)) for _, addr := range raw { @@ -1648,6 +1880,8 @@ func toAddressEmailList(raw []mailAddressOutput) []string { return out } +// toStringList coerces a JSON array of strings / anything-stringifiable +// into []string. Returns nil when v is not an array. func toStringList(v interface{}) []string { list, _ := v.([]interface{}) out := make([]string, 0, len(list)) @@ -1659,6 +1893,8 @@ func toStringList(v interface{}) []string { return out } +// toSecurityLevel extracts the risk-banner info from a raw message's +// security_level field. Returns nil when absent / not flagged. func toSecurityLevel(v interface{}) *mailSecurityLevelOutput { raw, ok := v.(map[string]interface{}) if !ok || raw == nil { @@ -1679,11 +1915,14 @@ func toSecurityLevel(v interface{}) *mailSecurityLevelOutput { } } +// boolVal returns v as a bool when it is one, otherwise false. func boolVal(v interface{}) bool { b, _ := v.(bool) return b } +// firstNonEmpty returns the first non-empty value in values, or "" when +// all values are empty. func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { @@ -1693,6 +1932,9 @@ func firstNonEmpty(values ...string) string { return "" } +// resolveAttachmentContentType returns the MIME type of an attachment, +// falling back to the extension-based guess when the API response doesn't +// include one. func resolveAttachmentContentType(att map[string]interface{}, filename string) string { if ct := strVal(att["content_type"]); ct != "" { return ct @@ -1705,6 +1947,9 @@ func resolveAttachmentContentType(att map[string]interface{}, filename string) s return "application/octet-stream" } +// messageStateText maps the numeric message_state code (1/2/3) to the +// human-readable label received / sent / draft. Unknown values become +// "unknown". func messageStateText(state int) string { switch state { case 1: @@ -1718,6 +1963,8 @@ func messageStateText(state int) string { } } +// priorityTypeText maps the server priority enum ("HIGH" / "LOW" / +// "NORMAL" / empty) to the CLI-facing label shown in message output. func priorityTypeText(priorityType string) string { switch priorityType { case "0": @@ -1843,6 +2090,9 @@ type originalMessage struct { ccAddressesFull []mailAddressPair // name+email pairs for quote display } +// normalizeMessageID strips angle brackets and whitespace from an RFC 5322 +// Message-ID so it can be used as a bare value in In-Reply-To / References +// headers (emlbuilder re-wraps in angle brackets itself). func normalizeMessageID(id string) string { trimmed := strings.TrimSpace(id) trimmed = strings.TrimPrefix(trimmed, "<") @@ -1850,6 +2100,9 @@ func normalizeMessageID(id string) string { return strings.TrimSpace(trimmed) } +// buildDraftSendOutput formats a successful drafts.send response into the +// public output map (message_id / thread_id plus an optional recall tip +// when the backend reports the message is within the recall window). func buildDraftSendOutput(resData map[string]interface{}, mailboxID string) map[string]interface{} { out := map[string]interface{}{ "message_id": resData["message_id"], @@ -1875,6 +2128,8 @@ func buildDraftSendOutput(resData map[string]interface{}, mailboxID string) map[ return out } +// buildDraftSavedOutput formats a successful drafts.create / drafts.update +// response into the public output map (draft_id + optional preview URL). func buildDraftSavedOutput(draftResult draftpkg.DraftResult, mailboxID string) map[string]interface{} { out := map[string]interface{}{ "draft_id": draftResult.DraftID, @@ -1886,6 +2141,9 @@ func buildDraftSavedOutput(draftResult draftpkg.DraftResult, mailboxID string) m return out } +// normalizeInlineCID strips angle brackets from a Content-ID so it can be +// referenced in and emlbuilder.AddFileInline +// consistently (both expect the bare CID). func normalizeInlineCID(cid string) string { trimmed := strings.TrimSpace(cid) if len(trimmed) >= 4 && strings.EqualFold(trimmed[:4], "cid:") { @@ -1917,6 +2175,13 @@ func validateInlineCIDs(html string, userCIDs, extraCIDs []string) error { return nil } +// addInlineImagesToBuilder downloads each inline image referenced in images +// and attaches it to bld with the caller-supplied CID preserved. Returns the +// extended builder, the list of CIDs that were actually attached (empty CIDs +// are skipped), and the total bytes of downloaded inline content (for +// attachment-size budgeting upstream). Errors propagate immediately; callers +// should not reuse the builder on error since partial state may have been +// committed. func addInlineImagesToBuilder(runtime *common.RuntimeContext, bld emlbuilder.Builder, images []inlineSourcePart) (emlbuilder.Builder, []string, int64, error) { var cids []string var totalBytes int64 @@ -2075,6 +2340,9 @@ func validateLabelReadScope(runtime *common.RuntimeContext) error { return nil } +// validateComposeHasAtLeastOneRecipient ensures a compose-style invocation +// has at least one recipient field populated. Returns ErrValidation when +// all three (to/cc/bcc) are empty or whitespace-only. func validateComposeHasAtLeastOneRecipient(to, cc, bcc string) error { if strings.TrimSpace(to) == "" && strings.TrimSpace(cc) == "" && strings.TrimSpace(bcc) == "" { return fmt.Errorf("at least one recipient (--to, --cc, or --bcc) is required") @@ -2092,6 +2360,10 @@ func validateRecipientCount(to, cc, bcc string) error { return nil } +// validateComposeInlineAndAttachments validates the --attach / --inline +// flag pair before sending: it rejects --inline with --plain-text or with +// a non-HTML body, and checks that every --attach path passes filename / +// extension / size rules via the shared filecheck rules. func validateComposeInlineAndAttachments(fio fileio.FileIO, attachFlag, inlineFlag string, plainText bool, body string) error { if strings.TrimSpace(inlineFlag) != "" { if plainText { diff --git a/shortcuts/mail/helpers_test.go b/shortcuts/mail/helpers_test.go index 291cc6b3b..18cbf5b6e 100644 --- a/shortcuts/mail/helpers_test.go +++ b/shortcuts/mail/helpers_test.go @@ -25,6 +25,7 @@ import ( "github.com/larksuite/cli/shortcuts/mail/emlbuilder" ) +// TestDecodeBodyFields verifies decode body fields. func TestDecodeBodyFields(t *testing.T) { htmlEncoded := base64.URLEncoding.EncodeToString([]byte("

Hello

")) plainEncoded := base64.RawURLEncoding.EncodeToString([]byte("Hello plain")) @@ -52,6 +53,7 @@ func TestDecodeBodyFields(t *testing.T) { } } +// TestDecodeBodyFieldsSkipsAbsent verifies decode body fields skips absent. func TestDecodeBodyFieldsSkipsAbsent(t *testing.T) { src := map[string]interface{}{"subject": "no body"} dst := map[string]interface{}{} @@ -61,6 +63,7 @@ func TestDecodeBodyFieldsSkipsAbsent(t *testing.T) { } } +// TestMessageFieldPolicy verifies message field policy. func TestMessageFieldPolicy(t *testing.T) { if !shouldExposeRawMessageField("custom_meta") { t.Fatalf("custom metadata should be auto-passed through") @@ -79,6 +82,7 @@ func TestMessageFieldPolicy(t *testing.T) { } } +// TestToForwardSourceAttachments verifies to forward source attachments. func TestToForwardSourceAttachments(t *testing.T) { out := normalizedMessageForCompose{ Attachments: []mailAttachmentOutput{ @@ -107,6 +111,7 @@ func TestToForwardSourceAttachments(t *testing.T) { // parseInlineSpecs // --------------------------------------------------------------------------- +// TestParseInlineSpecs_Empty verifies parse inline specs empty. func TestParseInlineSpecs_Empty(t *testing.T) { specs, err := parseInlineSpecs("") if err != nil { @@ -117,6 +122,7 @@ func TestParseInlineSpecs_Empty(t *testing.T) { } } +// TestParseInlineSpecs_Whitespace verifies parse inline specs whitespace. func TestParseInlineSpecs_Whitespace(t *testing.T) { specs, err := parseInlineSpecs(" ") if err != nil { @@ -127,6 +133,7 @@ func TestParseInlineSpecs_Whitespace(t *testing.T) { } } +// TestParseInlineSpecs_Valid verifies parse inline specs valid. func TestParseInlineSpecs_Valid(t *testing.T) { raw := `[{"cid":"YmFubmVyLnBuZw","file_path":"./banner.png"},{"cid":"bG9nby5wbmc","file_path":"/abs/logo.png"}]` specs, err := parseInlineSpecs(raw) @@ -150,6 +157,7 @@ func TestParseInlineSpecs_Valid(t *testing.T) { } } +// TestParseInlineSpecs_InvalidJSON verifies parse inline specs invalid JSON. func TestParseInlineSpecs_InvalidJSON(t *testing.T) { _, err := parseInlineSpecs(`not-json`) if err == nil { @@ -157,6 +165,7 @@ func TestParseInlineSpecs_InvalidJSON(t *testing.T) { } } +// TestParseInlineSpecs_MissingCID verifies parse inline specs missing CID. func TestParseInlineSpecs_MissingCID(t *testing.T) { _, err := parseInlineSpecs(`[{"cid":"","file_path":"./banner.png"}]`) if err == nil { @@ -164,6 +173,7 @@ func TestParseInlineSpecs_MissingCID(t *testing.T) { } } +// TestParseInlineSpecs_MissingFilePath verifies parse inline specs missing file path. func TestParseInlineSpecs_MissingFilePath(t *testing.T) { _, err := parseInlineSpecs(`[{"cid":"YmFubmVyLnBuZw","file_path":""}]`) if err == nil { @@ -171,6 +181,7 @@ func TestParseInlineSpecs_MissingFilePath(t *testing.T) { } } +// TestParseInlineSpecs_OldKeyRejected verifies parse inline specs old key rejected. func TestParseInlineSpecs_OldKeyRejected(t *testing.T) { // "file-path" (kebab) must not be recognised — only "file_path" (snake) is valid. // The JSON decoder will silently ignore unknown keys, so file_path stays empty → validation error. @@ -184,6 +195,7 @@ func TestParseInlineSpecs_OldKeyRejected(t *testing.T) { // inlineSpecFilePaths // --------------------------------------------------------------------------- +// TestInlineSpecFilePaths verifies inline spec file paths. func TestInlineSpecFilePaths(t *testing.T) { specs := []InlineSpec{ {CID: "cid1", FilePath: "./a.png"}, @@ -201,6 +213,7 @@ func TestInlineSpecFilePaths(t *testing.T) { } } +// TestInlineSpecFilePaths_Nil verifies inline spec file paths nil. func TestInlineSpecFilePaths_Nil(t *testing.T) { if paths := inlineSpecFilePaths(nil); paths != nil { t.Fatalf("expected nil for nil input, got %v", paths) @@ -211,6 +224,7 @@ func TestInlineSpecFilePaths_Nil(t *testing.T) { // validateForwardAttachmentURLs / validateInlineImageURLs // --------------------------------------------------------------------------- +// TestValidateForwardAttachmentURLs_MissingDownloadURL verifies validate forward attachment URLs missing download URL. func TestValidateForwardAttachmentURLs_MissingDownloadURL(t *testing.T) { src := composeSourceMessage{ ForwardAttachments: []forwardSourceAttachment{ @@ -227,6 +241,7 @@ func TestValidateForwardAttachmentURLs_MissingDownloadURL(t *testing.T) { } } +// TestValidateForwardAttachmentURLs_IgnoresInlineImages verifies validate forward attachment URLs ignores inline images. func TestValidateForwardAttachmentURLs_IgnoresInlineImages(t *testing.T) { src := composeSourceMessage{ ForwardAttachments: []forwardSourceAttachment{ @@ -241,6 +256,7 @@ func TestValidateForwardAttachmentURLs_IgnoresInlineImages(t *testing.T) { } } +// TestValidateForwardAttachmentURLs_AllPresent verifies validate forward attachment URLs all present. func TestValidateForwardAttachmentURLs_AllPresent(t *testing.T) { src := composeSourceMessage{ ForwardAttachments: []forwardSourceAttachment{ @@ -255,6 +271,7 @@ func TestValidateForwardAttachmentURLs_AllPresent(t *testing.T) { } } +// TestValidateInlineImageURLs_MissingDownloadURL verifies validate inline image URLs missing download URL. func TestValidateInlineImageURLs_MissingDownloadURL(t *testing.T) { src := composeSourceMessage{ ForwardAttachments: []forwardSourceAttachment{ @@ -273,6 +290,7 @@ func TestValidateInlineImageURLs_MissingDownloadURL(t *testing.T) { } } +// TestValidateInlineImageURLs_IgnoresAttachments verifies validate inline image URLs ignores attachments. func TestValidateInlineImageURLs_IgnoresAttachments(t *testing.T) { // Inline images are fine; attachments have missing URLs but should NOT be checked. src := composeSourceMessage{ @@ -288,6 +306,7 @@ func TestValidateInlineImageURLs_IgnoresAttachments(t *testing.T) { } } +// TestToForwardSourceAttachments_PreservesMissingURL verifies to forward source attachments preserves missing URL. func TestToForwardSourceAttachments_PreservesMissingURL(t *testing.T) { out := normalizedMessageForCompose{ Attachments: []mailAttachmentOutput{ @@ -301,6 +320,7 @@ func TestToForwardSourceAttachments_PreservesMissingURL(t *testing.T) { } } +// TestToInlineSourceParts_PreservesMissingURL verifies to inline source parts preserves missing URL. func TestToInlineSourceParts_PreservesMissingURL(t *testing.T) { out := normalizedMessageForCompose{ Images: []mailImageOutput{ @@ -328,6 +348,7 @@ func newDownloadRuntime(t *testing.T, client *http.Client) *common.RuntimeContex return rt } +// TestDownloadAttachmentContent_RejectsHTTP verifies download attachment content rejects h t t p. func TestDownloadAttachmentContent_RejectsHTTP(t *testing.T) { rt := newDownloadRuntime(t, &http.Client{}) _, err := downloadAttachmentContent(rt, "http://evil.example.com/file") @@ -336,6 +357,7 @@ func TestDownloadAttachmentContent_RejectsHTTP(t *testing.T) { } } +// TestDownloadAttachmentContent_RejectsFileScheme verifies download attachment content rejects file scheme. func TestDownloadAttachmentContent_RejectsFileScheme(t *testing.T) { rt := newDownloadRuntime(t, &http.Client{}) _, err := downloadAttachmentContent(rt, "file:///etc/passwd") @@ -344,6 +366,7 @@ func TestDownloadAttachmentContent_RejectsFileScheme(t *testing.T) { } } +// TestDownloadAttachmentContent_RejectsEmptyHost verifies download attachment content rejects empty host. func TestDownloadAttachmentContent_RejectsEmptyHost(t *testing.T) { rt := newDownloadRuntime(t, &http.Client{}) _, err := downloadAttachmentContent(rt, "https:///no-host") @@ -352,6 +375,7 @@ func TestDownloadAttachmentContent_RejectsEmptyHost(t *testing.T) { } } +// TestDownloadAttachmentContent_NoAuthorizationHeader verifies download attachment content no authorization header. func TestDownloadAttachmentContent_NoAuthorizationHeader(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "" { @@ -392,6 +416,7 @@ func newOutputRuntime(t *testing.T) (*common.RuntimeContext, *bytes.Buffer, *byt // printMessageOutputSchema // --------------------------------------------------------------------------- +// TestPrintMessageOutputSchema verifies print message output schema. func TestPrintMessageOutputSchema(t *testing.T) { rt, stdout, _ := newOutputRuntime(t) printMessageOutputSchema(rt) @@ -416,6 +441,7 @@ func TestPrintMessageOutputSchema(t *testing.T) { // printWatchOutputSchema // --------------------------------------------------------------------------- +// TestPrintWatchOutputSchema verifies print watch output schema. func TestPrintWatchOutputSchema(t *testing.T) { rt, stdout, _ := newOutputRuntime(t) printWatchOutputSchema(rt) @@ -436,6 +462,7 @@ func TestPrintWatchOutputSchema(t *testing.T) { // hintMarkAsRead — sanitizeForTerminal integration // --------------------------------------------------------------------------- +// TestHintMarkAsRead verifies hint mark as read. func TestHintMarkAsRead(t *testing.T) { rt, _, stderr := newOutputRuntime(t) // Inject ANSI escape + message ID to verify sanitization @@ -453,6 +480,7 @@ func TestHintMarkAsRead(t *testing.T) { // intVal — json.Number // --------------------------------------------------------------------------- +// TestIntVal_JsonNumber verifies int val json number. func TestIntVal_JsonNumber(t *testing.T) { n := json.Number("42") got := intVal(n) @@ -461,6 +489,7 @@ func TestIntVal_JsonNumber(t *testing.T) { } } +// TestIntVal_JsonNumberInvalid verifies int val json number invalid. func TestIntVal_JsonNumberInvalid(t *testing.T) { n := json.Number("not-a-number") got := intVal(n) @@ -473,6 +502,7 @@ func TestIntVal_JsonNumberInvalid(t *testing.T) { // toOriginalMessageForCompose // --------------------------------------------------------------------------- +// TestToOriginalMessageForCompose verifies to original message for compose. func TestToOriginalMessageForCompose(t *testing.T) { out := normalizedMessageForCompose{ Subject: "Test Subject\r\nBcc: evil@evil.com", @@ -539,6 +569,7 @@ func TestToOriginalMessageForCompose(t *testing.T) { } } +// TestToOriginalMessageForCompose_NoHTML verifies to original message for compose no HTML. func TestToOriginalMessageForCompose_NoHTML(t *testing.T) { out := normalizedMessageForCompose{ Subject: "Plain email", @@ -554,6 +585,7 @@ func TestToOriginalMessageForCompose_NoHTML(t *testing.T) { } } +// TestToOriginalMessageForCompose_EmptyReferences verifies to original message for compose empty references. func TestToOriginalMessageForCompose_EmptyReferences(t *testing.T) { out := normalizedMessageForCompose{ From: mailAddressOutput{Email: "alice@example.com"}, @@ -569,6 +601,7 @@ func TestToOriginalMessageForCompose_EmptyReferences(t *testing.T) { // validateInlineCIDs — bidirectional CID consistency // --------------------------------------------------------------------------- +// TestValidateInlineCIDs_UserOrphanError verifies validate inline c i ds user orphan error. func TestValidateInlineCIDs_UserOrphanError(t *testing.T) { // User-provided CID not referenced in body → error. err := validateInlineCIDs(`

no image

`, []string{"orphan-cid"}, nil) @@ -580,6 +613,7 @@ func TestValidateInlineCIDs_UserOrphanError(t *testing.T) { } } +// TestValidateInlineCIDs_SourceOrphanAllowed verifies validate inline c i ds source orphan allowed. func TestValidateInlineCIDs_SourceOrphanAllowed(t *testing.T) { // Source-message CID not referenced in body → allowed (quoting may drop references). err := validateInlineCIDs(`

no image

`, nil, []string{"source-unused"}) @@ -588,6 +622,7 @@ func TestValidateInlineCIDs_SourceOrphanAllowed(t *testing.T) { } } +// TestValidateInlineCIDs_SourceAndUserMixed verifies validate inline c i ds source and user mixed. func TestValidateInlineCIDs_SourceAndUserMixed(t *testing.T) { // Body references both a source CID and a user CID. // Source has an extra unreferenced CID — should not error. @@ -598,6 +633,7 @@ func TestValidateInlineCIDs_SourceAndUserMixed(t *testing.T) { } } +// TestValidateInlineCIDs_MissingRefError verifies validate inline c i ds missing ref error. func TestValidateInlineCIDs_MissingRefError(t *testing.T) { // Body references a CID that nobody provided → error. html := `

` @@ -610,6 +646,7 @@ func TestValidateInlineCIDs_MissingRefError(t *testing.T) { } } +// TestValidateInlineCIDs_MissingRefSatisfiedBySource verifies validate inline c i ds missing ref satisfied by source. func TestValidateInlineCIDs_MissingRefSatisfiedBySource(t *testing.T) { // Body references a CID that only exists in source (extraCIDs) → ok. html := `

` @@ -619,6 +656,7 @@ func TestValidateInlineCIDs_MissingRefSatisfiedBySource(t *testing.T) { } } +// TestValidateInlineCIDs_NoCIDsNoError verifies validate inline c i ds no c i ds no error. func TestValidateInlineCIDs_NoCIDsNoError(t *testing.T) { err := validateInlineCIDs(`

plain text

`, nil, nil) if err != nil { @@ -630,6 +668,7 @@ func TestValidateInlineCIDs_NoCIDsNoError(t *testing.T) { // downloadAttachmentContent — size limit enforcement // --------------------------------------------------------------------------- +// TestDownloadAttachmentContent_HTTP4xx verifies download attachment content h t t p4xx. func TestDownloadAttachmentContent_HTTP4xx(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "not found", http.StatusNotFound) @@ -643,6 +682,7 @@ func TestDownloadAttachmentContent_HTTP4xx(t *testing.T) { } } +// TestDownloadAttachmentContent_SizeLimit verifies download attachment content size limit. func TestDownloadAttachmentContent_SizeLimit(t *testing.T) { // Return a response that claims to be larger than MaxAttachmentDownloadBytes // We can't actually write 35MB in a test, but we can test the limit logic @@ -666,6 +706,7 @@ func TestDownloadAttachmentContent_SizeLimit(t *testing.T) { // buildReplyAllRecipients — no-mutation of excluded map (tests the copy fix) // --------------------------------------------------------------------------- +// TestBuildReplyAllRecipients_DoesNotMutateExcluded verifies build reply all recipients does not mutate excluded. func TestBuildReplyAllRecipients_DoesNotMutateExcluded(t *testing.T) { excluded := map[string]bool{"blocked@example.com": true} originalLen := len(excluded) @@ -679,6 +720,7 @@ func TestBuildReplyAllRecipients_DoesNotMutateExcluded(t *testing.T) { // addInlineImagesToBuilder — with empty CID skip // --------------------------------------------------------------------------- +// TestAddInlineImagesToBuilder_EmptyCIDSkipped verifies add inline images to builder empty CID skipped. func TestAddInlineImagesToBuilder_EmptyCIDSkipped(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "imagedata") @@ -699,6 +741,7 @@ func TestAddInlineImagesToBuilder_EmptyCIDSkipped(t *testing.T) { } } +// TestAddInlineImagesToBuilder_Success verifies add inline images to builder success. func TestAddInlineImagesToBuilder_Success(t *testing.T) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "imagedata") @@ -734,6 +777,7 @@ func TestAddInlineImagesToBuilder_Success(t *testing.T) { // normalizeInlineCID // --------------------------------------------------------------------------- +// TestNormalizeInlineCID verifies normalize inline CID. func TestNormalizeInlineCID(t *testing.T) { tests := []struct { input, want string @@ -754,6 +798,7 @@ func TestNormalizeInlineCID(t *testing.T) { } } +// TestResolveComposeMailboxID verifies resolve compose mailbox ID. func TestResolveComposeMailboxID(t *testing.T) { tests := []struct { name string @@ -785,6 +830,7 @@ func TestResolveComposeMailboxID(t *testing.T) { } } +// TestResolveComposeSenderEmail verifies resolve compose sender email. func TestResolveComposeSenderEmail(t *testing.T) { // Note: the "no flags" case falls through to fetchMailboxPrimaryEmail which // requires an API client. That path is covered by integration/shortcut tests. @@ -822,6 +868,7 @@ func TestResolveComposeSenderEmail(t *testing.T) { } } +// TestParseNetAddrs_Dedup verifies parse net addrs dedup. func TestParseNetAddrs_Dedup(t *testing.T) { tests := []struct { name string @@ -872,6 +919,7 @@ func TestParseNetAddrs_Dedup(t *testing.T) { // validateRecipientCount // --------------------------------------------------------------------------- +// TestValidateRecipientCount verifies validate recipient count. func TestValidateRecipientCount(t *testing.T) { t.Run("under limit", func(t *testing.T) { err := validateRecipientCount("a@x.com, b@x.com", "c@x.com", "d@x.com") @@ -946,6 +994,7 @@ func TestValidateRecipientCount(t *testing.T) { }) } +// TestValidateComposeHasAtLeastOneRecipient_AlsoChecksCount verifies validate compose has at least one recipient also checks count. func TestValidateComposeHasAtLeastOneRecipient_AlsoChecksCount(t *testing.T) { // Verify that validateComposeHasAtLeastOneRecipient also enforces the count limit addrs := make([]string, MaxRecipientCount+1) @@ -980,6 +1029,7 @@ func newSendTimeRuntime(t *testing.T, sendTime string, confirmSend bool) *common return &common.RuntimeContext{Cmd: cmd} } +// TestValidateSendTime_Empty verifies validate send time empty. func TestValidateSendTime_Empty(t *testing.T) { rt := newSendTimeRuntime(t, "", false) if err := validateSendTime(rt); err != nil { @@ -987,6 +1037,7 @@ func TestValidateSendTime_Empty(t *testing.T) { } } +// TestValidateSendTime_RequiresConfirmSend verifies validate send time requires confirm send. func TestValidateSendTime_RequiresConfirmSend(t *testing.T) { future := strconv.FormatInt(time.Now().Unix()+10*60, 10) rt := newSendTimeRuntime(t, future, false) @@ -999,6 +1050,7 @@ func TestValidateSendTime_RequiresConfirmSend(t *testing.T) { } } +// TestValidateSendTime_InvalidInteger verifies validate send time invalid integer. func TestValidateSendTime_InvalidInteger(t *testing.T) { rt := newSendTimeRuntime(t, "not-a-number", true) err := validateSendTime(rt) @@ -1010,6 +1062,7 @@ func TestValidateSendTime_InvalidInteger(t *testing.T) { } } +// TestValidateSendTime_TooSoon verifies validate send time too soon. func TestValidateSendTime_TooSoon(t *testing.T) { // Just 1 minute in the future — below the 5-minute minimum. soon := strconv.FormatInt(time.Now().Unix()+60, 10) @@ -1023,6 +1076,7 @@ func TestValidateSendTime_TooSoon(t *testing.T) { } } +// TestValidateSendTime_Valid verifies validate send time valid. func TestValidateSendTime_Valid(t *testing.T) { future := strconv.FormatInt(time.Now().Unix()+10*60, 10) rt := newSendTimeRuntime(t, future, true) @@ -1031,6 +1085,7 @@ func TestValidateSendTime_Valid(t *testing.T) { } } +// TestParsePriority verifies parse priority. func TestParsePriority(t *testing.T) { cases := []struct { name string @@ -1066,6 +1121,7 @@ func TestParsePriority(t *testing.T) { } } +// TestBuildMessageOutput_PriorityFromLabels verifies build message output priority from labels. func TestBuildMessageOutput_PriorityFromLabels(t *testing.T) { cases := []struct { name string @@ -1102,6 +1158,7 @@ func TestBuildMessageOutput_PriorityFromLabels(t *testing.T) { } } +// TestApplyPriority verifies apply priority. func TestApplyPriority(t *testing.T) { // Empty priority: EML must not contain X-Cli-Priority header. emptyBld := emlbuilder.New(). @@ -1136,6 +1193,7 @@ func TestApplyPriority(t *testing.T) { } } +// TestValidatePriorityFlag verifies validate priority flag. func TestValidatePriorityFlag(t *testing.T) { makeRuntime := func(priority string) *common.RuntimeContext { cmd := &cobra.Command{Use: "test"} @@ -1171,6 +1229,7 @@ func TestValidatePriorityFlag(t *testing.T) { } } +// TestBuildMessageForCompose_InlineNoCID_ClassifiedAsAttachment verifies build message for compose inline no CID classified as attachment. func TestBuildMessageForCompose_InlineNoCID_ClassifiedAsAttachment(t *testing.T) { msg := map[string]interface{}{ "message_id": "msg1", @@ -1198,6 +1257,7 @@ func TestBuildMessageForCompose_InlineNoCID_ClassifiedAsAttachment(t *testing.T) // validateComposeInlineAndAttachments // --------------------------------------------------------------------------- +// TestValidateComposeInlineAndAttachments verifies validate compose inline and attachments. func TestValidateComposeInlineAndAttachments(t *testing.T) { chdirTemp(t) fio := &localfileio.LocalFileIO{} @@ -1260,3 +1320,130 @@ func TestValidateComposeInlineAndAttachments(t *testing.T) { } }) } + +// newRequestReceiptRuntime registers the --request-receipt bool flag alone +// (no --from), so requireSenderForRequestReceipt tests can drive the flag +// directly without pulling in unrelated compose plumbing. +func newRequestReceiptRuntime(t *testing.T, requestReceipt bool) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Bool("request-receipt", false, "") + if requestReceipt { + _ = cmd.Flags().Set("request-receipt", "true") + } + return &common.RuntimeContext{Cmd: cmd} +} + +// TestRequireSenderForRequestReceipt verifies require sender for request receipt. +func TestRequireSenderForRequestReceipt(t *testing.T) { + cases := []struct { + name string + requestReceipt bool + senderEmail string + wantErr bool + }{ + {"flag unset, empty sender ok", false, "", false}, + {"flag unset, with sender ok", false, "alice@example.com", false}, + {"flag set, empty sender errors", true, "", true}, + {"flag set, whitespace-only sender errors", true, " ", true}, + {"flag set, with sender ok", true, "alice@example.com", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := requireSenderForRequestReceipt( + newRequestReceiptRuntime(t, tc.requestReceipt), tc.senderEmail) + if tc.wantErr && err == nil { + t.Errorf("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + if tc.wantErr && err != nil && !strings.Contains(err.Error(), "--request-receipt") { + t.Errorf("error message should mention --request-receipt, got: %v", err) + } + }) + } +} + +// TestShellQuoteForHint verifies shell quote for hint. +func TestShellQuoteForHint(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"plain", "user@example.com", "user@example.com"}, + {"with single quote", "O'Brien", `O'\''Brien`}, + {"with space", "hello world", "hello world"}, + {"mixed", "it's a test", `it'\''s a test`}, + {"empty", "", ""}, + // The single-line sanitizer must strip embedded newlines so a crafted + // mailboxID / messageID can't forge extra lines in a hint. + {"with newline stripped", "abc\ndef", "abcdef"}, + {"with CR + LF stripped", "abc\r\ndef", "abcdef"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := shellQuoteForHint(tc.in); got != tc.want { + t.Errorf("shellQuoteForHint(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestSanitizeForSingleLine verifies sanitize for single line. +func TestSanitizeForSingleLine(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"plain passes through", "alice@example.com", "alice@example.com"}, + {"strips LF", "alice@example.com\ntip: forged", "alice@example.comtip: forged"}, + {"strips CR+LF", "x\r\ny", "xy"}, + {"strips ANSI + LF", "\x1b[31mred\x1b[0m\nnext", "rednext"}, + {"keeps tab", "a\tb", "a\tb"}, + {"strips bidi override", "a\u202eb", "ab"}, + {"empty", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := sanitizeForSingleLine(tc.in); got != tc.want { + t.Errorf("sanitizeForSingleLine(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestValidateHeaderAddress verifies validate header address. +func TestValidateHeaderAddress(t *testing.T) { + cases := []struct { + name string + in string + wantErr string // substring expected in error, "" = no error + }{ + {"plain", "alice@example.com", ""}, + {"tab allowed for folded headers", "alice@example.com\tcomment", ""}, + {"lf rejected", "alice@example.com\nX-Injected: 1", "control character"}, + {"cr rejected", "alice@example.com\rsomething", "control character"}, + {"del rejected", "alice@example.com\x7f", "control character"}, + {"bidi override rejected", "alice@example.com\u202e", "dangerous Unicode"}, + {"zero-width rejected", "ali\u200bce@example.com", "dangerous Unicode"}, + {"empty ok", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateHeaderAddress(tc.in) + if tc.wantErr == "" && err != nil { + t.Errorf("expected no error, got %v", err) + } + if tc.wantErr != "" { + if err == nil { + t.Errorf("expected error containing %q, got nil", tc.wantErr) + } else if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("expected error containing %q, got %v", tc.wantErr, err) + } + } + }) + } +} diff --git a/shortcuts/mail/mail_decline_receipt.go b/shortcuts/mail/mail_decline_receipt.go new file mode 100644 index 000000000..6b3dbb374 --- /dev/null +++ b/shortcuts/mail/mail_decline_receipt.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/shortcuts/common" +) + +// MailDeclineReceipt is the `+decline-receipt` shortcut: dismiss the read- +// receipt request banner on an incoming message WITHOUT sending a receipt. +// Mirrors the Lark client's "不发送" button next to the read-receipt prompt — +// the client talks to the internal MessageModify RPC with RemoveLabelIds= +// ["-607"]; this shortcut calls the public OpenAPI user_mailbox.message.modify +// which accepts the symbolic "READ_RECEIPT_REQUEST" label name (the public +// endpoint performs the symbolic→numeric translation server-side). Either +// path lands on the same MessageModify codepath, closing the banner. +// Removes only the READ_RECEIPT_REQUEST system label; no outgoing mail is +// produced. Idempotent: running it on a message that no longer carries the +// label is a no-op, not an error. +var MailDeclineReceipt = common.Shortcut{ + Service: "mail", + Command: "+decline-receipt", + Description: "Dismiss the read-receipt request banner on an incoming mail by clearing its READ_RECEIPT_REQUEST label, without sending a receipt. Use when the user wants to silence the prompt but refuse to confirm they have read it. Idempotent — safe to re-run.", + Risk: "write", + Scopes: []string{ + "mail:user_mailbox.message:modify", + "mail:user_mailbox.message:readonly", + "mail:user_mailbox:readonly", + // fetchFullMessage(..., false) uses format=plain_text_full which the + // backend scope-checks against body:read even though we only inspect + // label_ids. Declared explicitly to keep Scopes truthful. + "mail:user_mailbox.message.body:read", + }, + AuthTypes: []string{"user"}, + Flags: []common.Flag{ + {Name: "message-id", Desc: "Required. Message ID of the incoming mail that requested a read receipt.", Required: true}, + {Name: "mailbox", Desc: "Mailbox email address that owns the message (default: me)."}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + messageID := runtime.Str("message-id") + mailboxID := resolveComposeMailboxID(runtime) + return common.NewDryRunAPI(). + Desc("Decline read receipt: fetch the original message → verify the READ_RECEIPT_REQUEST label is present → PUT user_mailbox.message.modify (the OpenAPI wrapper around the MessageModify RPC the Lark client's \"不发送\" button triggers) with remove_label_ids=[\"READ_RECEIPT_REQUEST\"]. No outgoing mail is produced; the banner is cleared locally. Idempotent when the label is already absent."). + GET(mailboxPath(mailboxID, "messages", messageID)). + Params(map[string]interface{}{"format": messageGetFormat(false)}). + PUT(mailboxPath(mailboxID, "messages", messageID, "modify")). + Body(map[string]interface{}{ + "remove_label_ids": []string{readReceiptRequestLabel}, + }) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + messageID := runtime.Str("message-id") + mailboxID := resolveComposeMailboxID(runtime) + + msg, err := fetchFullMessage(runtime, mailboxID, messageID, false) + if err != nil { + return fmt.Errorf("failed to fetch original message: %w", err) + } + + out := map[string]interface{}{ + "message_id": messageID, + "decline_receipt_for_id": messageID, + } + + if !hasReadReceiptRequestLabel(msg) { + out["declined"] = false + out["already_cleared"] = true + runtime.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintln(w, "Read-receipt request already cleared — nothing to do.") + fmt.Fprintf(w, "message_id: %s\n", messageID) + }) + return nil + } + + if _, err := runtime.CallAPI("PUT", + mailboxPath(mailboxID, "messages", messageID, "modify"), + nil, + map[string]interface{}{ + "remove_label_ids": []string{readReceiptRequestLabel}, + }, + ); err != nil { + return fmt.Errorf("failed to clear READ_RECEIPT_REQUEST label: %w", err) + } + + out["declined"] = true + runtime.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintln(w, "已关闭已读回执请求(未发送回执)/ Read-receipt request dismissed (no receipt sent).") + fmt.Fprintf(w, "message_id: %s\n", messageID) + }) + return nil + }, +} diff --git a/shortcuts/mail/mail_decline_receipt_test.go b/shortcuts/mail/mail_decline_receipt_test.go new file mode 100644 index 000000000..9bd001bcb --- /dev/null +++ b/shortcuts/mail/mail_decline_receipt_test.go @@ -0,0 +1,146 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/shortcuts/common" +) + +// TestMailDeclineReceipt_ShortcutMetadata verifies the shortcut is registered +// with the expected command name, risk level, and scopes. These are public +// contracts (they show up in `lark-cli mail --help` and the auth prompt); +// changes should be intentional. +func TestMailDeclineReceipt_ShortcutMetadata(t *testing.T) { + if MailDeclineReceipt.Service != "mail" { + t.Errorf("Service = %q, want %q", MailDeclineReceipt.Service, "mail") + } + if MailDeclineReceipt.Command != "+decline-receipt" { + t.Errorf("Command = %q, want %q", MailDeclineReceipt.Command, "+decline-receipt") + } + // +decline-receipt only removes a local label, no outgoing mail — Risk is + // "write", not "high-risk-write" that +send-receipt uses. Writers should + // not need --yes. + if MailDeclineReceipt.Risk != "write" { + t.Errorf("Risk = %q, want %q", MailDeclineReceipt.Risk, "write") + } + // modify scope is required to flip label_ids; readonly scopes are + // required because fetchFullMessage(..., false) hits plain_text_full + // which the backend scope-checks against body:read. + required := map[string]bool{ + "mail:user_mailbox.message:modify": true, + "mail:user_mailbox.message:readonly": true, + "mail:user_mailbox:readonly": true, + "mail:user_mailbox.message.body:read": true, + } + for _, s := range MailDeclineReceipt.Scopes { + delete(required, s) + } + if len(required) != 0 { + t.Errorf("MailDeclineReceipt.Scopes missing %v", required) + } + if len(MailDeclineReceipt.AuthTypes) != 1 || MailDeclineReceipt.AuthTypes[0] != "user" { + t.Errorf("AuthTypes = %v, want [user]", MailDeclineReceipt.AuthTypes) + } + // --message-id must be marked Required so the framework fails fast + // before we enter Execute; otherwise the fetchFullMessage call would + // hit the API with an empty id. + var found bool + for _, f := range MailDeclineReceipt.Flags { + if f.Name == "message-id" && f.Required { + found = true + break + } + } + if !found { + t.Error("--message-id flag must be marked Required") + } +} + +// runtimeForMailDeclineReceiptDryRun builds a minimal runtime with the flags +// MailDeclineReceipt declares, mirroring the pattern used by +// runtimeForMailTriageTest in mail_triage_test.go. +func runtimeForMailDeclineReceiptDryRun(t *testing.T, values map[string]string) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "test"} + for _, fl := range MailDeclineReceipt.Flags { + cmd.Flags().String(fl.Name, "", "") + } + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("parse flags failed: %v", err) + } + for k, v := range values { + if err := cmd.Flags().Set(k, v); err != nil { + t.Fatalf("set flag --%s failed: %v", k, err) + } + } + return &common.RuntimeContext{Cmd: cmd} +} + +// TestMailDeclineReceipt_DryRun verifies the DryRun plan prints the two calls +// the Execute path performs: a GET to fetch the original message (so we can +// check the READ_RECEIPT_REQUEST label is present) and a PUT to +// user_mailbox.message.modify that removes the label by its symbolic name. +// Pinning both URLs, methods, and the body shape here means a regression +// that reverts to the batch endpoint or leaks the numeric "-607" id shows +// up immediately without requiring a full integration round trip. +func TestMailDeclineReceipt_DryRun(t *testing.T) { + runtime := runtimeForMailDeclineReceiptDryRun(t, map[string]string{ + "message-id": "msg-1", + }) + + dry := MailDeclineReceipt.DryRun(context.Background(), runtime) + raw, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry-run failed: %v", err) + } + s := string(raw) + + for _, want := range []string{ + `"method":"GET"`, + `/user_mailboxes/me/messages/msg-1`, + `"method":"PUT"`, + `/user_mailboxes/me/messages/msg-1/modify`, + `"remove_label_ids":["READ_RECEIPT_REQUEST"]`, + } { + if !strings.Contains(s, want) { + t.Errorf("dry-run JSON missing %q; got:\n%s", want, s) + } + } + // Regression guards: batch endpoint shape and internal numeric id must + // not leak into the OpenAPI payload. + for _, forbidden := range []string{ + "batch_modify_message", + `"-607"`, + `"message_ids"`, + } { + if strings.Contains(s, forbidden) { + t.Errorf("dry-run JSON should not contain %q; got:\n%s", forbidden, s) + } + } +} + +// TestMailDeclineReceipt_DescriptionCoversFeatureIntent makes the Shortcut +// Description a tested string — it is the human-readable explanation piped +// into SKILL.md's Shortcut index table by the generator, so changes there +// should be intentional. +func TestMailDeclineReceipt_DescriptionCoversFeatureIntent(t *testing.T) { + desc := strings.ToLower(MailDeclineReceipt.Description) + for _, want := range []string{ + "dismiss", + "without sending", + "read_receipt_request", + "idempotent", + } { + if !strings.Contains(desc, want) { + t.Errorf("Description should mention %q; got: %s", want, MailDeclineReceipt.Description) + } + } +} diff --git a/shortcuts/mail/mail_draft_create.go b/shortcuts/mail/mail_draft_create.go index 2900c9bfc..aee77b328 100644 --- a/shortcuts/mail/mail_draft_create.go +++ b/shortcuts/mail/mail_draft_create.go @@ -15,6 +15,9 @@ import ( "github.com/larksuite/cli/shortcuts/mail/emlbuilder" ) +// draftCreateInput bundles all +draft-create user flags into a single +// struct so parseDraftCreateInput / buildRawEMLForDraftCreate have a +// uniform value type to pass around. type draftCreateInput struct { To string Subject string @@ -27,6 +30,9 @@ type draftCreateInput struct { PlainText bool } +// MailDraftCreate is the `+draft-create` shortcut: create a brand-new mail +// draft from scratch. For reply drafts use +reply; for forward drafts use +// +forward. var MailDraftCreate = common.Shortcut{ Service: "mail", Command: "+draft-create", @@ -46,6 +52,7 @@ var MailDraftCreate = common.Shortcut{ {Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring HTML auto-detection. Cannot be used with --inline."}, {Name: "attach", Desc: "Optional. Regular attachment file paths (relative path only). Separate multiple paths with commas. Each path must point to a readable local file."}, {Name: "inline", Desc: "Optional. Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."}, + {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."}, signatureFlag, priorityFlag, }, @@ -121,6 +128,10 @@ var MailDraftCreate = common.Shortcut{ }, } +// parseDraftCreateInput collects the +draft-create flags into a +// draftCreateInput struct and runs the minimum required-field checks +// (--subject and --body must be non-empty). Returns ErrValidation when a +// required field is missing. func parseDraftCreateInput(runtime *common.RuntimeContext) (draftCreateInput, error) { input := draftCreateInput{ To: runtime.Str("to"), @@ -142,6 +153,15 @@ func parseDraftCreateInput(runtime *common.RuntimeContext) (draftCreateInput, er return input, nil } +// buildRawEMLForDraftCreate assembles a base64url-encoded EML for the +// +draft-create shortcut. It resolves the sender from runtime / input, +// validates recipient counts, applies signature templates, resolves local +// image paths to CID-referenced inline parts, enforces attachment limits, +// applies priority headers, and optionally adds the Disposition-Notification- +// To header when --request-receipt is set. senderEmail is required; empty +// senderEmail returns an error early. The returned string is ready to POST +// to the drafts endpoint. ctx is plumbed through for large-attachment +// processing. func buildRawEMLForDraftCreate(ctx context.Context, runtime *common.RuntimeContext, input draftCreateInput, sigResult *signatureResult, priority string) (string, error) { senderEmail := resolveComposeSenderEmail(runtime) if senderEmail == "" { @@ -161,6 +181,17 @@ func buildRawEMLForDraftCreate(ctx context.Context, runtime *common.RuntimeConte if senderEmail != "" { bld = bld.From("", senderEmail) } + // senderEmail non-emptiness is already enforced above (L140); the flag- + // driven guard here only exists to make the relationship explicit to + // readers. requireSenderForRequestReceipt unifies this with the other + // compose shortcuts; if it ever trips in this path, the above check + // regressed. + if err := requireSenderForRequestReceipt(runtime, senderEmail); err != nil { + return "", err + } + if runtime.Bool("request-receipt") { + bld = bld.DispositionNotificationTo("", senderEmail) + } if input.CC != "" { bld = bld.CCAddrs(parseNetAddrs(input.CC)) } diff --git a/shortcuts/mail/mail_draft_create_test.go b/shortcuts/mail/mail_draft_create_test.go index 969240cd5..b3455b40c 100644 --- a/shortcuts/mail/mail_draft_create_test.go +++ b/shortcuts/mail/mail_draft_create_test.go @@ -25,6 +25,7 @@ func newRuntimeWithFrom(from string) *common.RuntimeContext { return &common.RuntimeContext{Cmd: cmd} } +// TestBuildRawEMLForDraftCreate_ResolvesLocalImages verifies build raw EML for draft create resolves local images. func TestBuildRawEMLForDraftCreate_ResolvesLocalImages(t *testing.T) { chdirTemp(t) os.WriteFile("test_image.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) @@ -53,6 +54,7 @@ func TestBuildRawEMLForDraftCreate_ResolvesLocalImages(t *testing.T) { } } +// TestBuildRawEMLForDraftCreate_NoLocalImages verifies build raw EML for draft create no local images. func TestBuildRawEMLForDraftCreate_NoLocalImages(t *testing.T) { input := draftCreateInput{ From: "sender@example.com", @@ -75,6 +77,7 @@ func TestBuildRawEMLForDraftCreate_NoLocalImages(t *testing.T) { } } +// TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit verifies build raw EML for draft create auto resolve counted in size limit. func TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit(t *testing.T) { chdirTemp(t) // Create a 1KB PNG file — small, but enough to push over the limit @@ -104,6 +107,7 @@ func TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit(t *testing.T) { } } +// TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError verifies build raw EML for draft create orphaned inline spec error. func TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError(t *testing.T) { chdirTemp(t) os.WriteFile("unused.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) @@ -124,6 +128,7 @@ func TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError(t *testing.T) { } } +// TestBuildRawEMLForDraftCreate_MissingCIDRefError verifies build raw EML for draft create missing CID ref error. func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) { chdirTemp(t) os.WriteFile("present.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) @@ -144,6 +149,7 @@ func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) { } } +// TestBuildRawEMLForDraftCreate_WithPriority verifies build raw EML for draft create with priority. func TestBuildRawEMLForDraftCreate_WithPriority(t *testing.T) { input := draftCreateInput{ From: "sender@example.com", @@ -161,6 +167,7 @@ func TestBuildRawEMLForDraftCreate_WithPriority(t *testing.T) { } } +// TestBuildRawEMLForDraftCreate_NoPriority verifies build raw EML for draft create no priority. func TestBuildRawEMLForDraftCreate_NoPriority(t *testing.T) { input := draftCreateInput{ From: "sender@example.com", @@ -178,6 +185,67 @@ func TestBuildRawEMLForDraftCreate_NoPriority(t *testing.T) { } } +// newRuntimeWithFromAndRequestReceipt mirrors newRuntimeWithFrom but also +// exposes the --request-receipt bool flag so tests can exercise the +// Disposition-Notification-To / validation-error paths gated by that flag. +func newRuntimeWithFromAndRequestReceipt(from string, requestReceipt bool) *common.RuntimeContext { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("from", "", "") + cmd.Flags().String("mailbox", "", "") + cmd.Flags().Bool("request-receipt", false, "") + if from != "" { + _ = cmd.Flags().Set("from", from) + } + if requestReceipt { + _ = cmd.Flags().Set("request-receipt", "true") + } + return &common.RuntimeContext{Cmd: cmd} +} + +// TestBuildRawEMLForDraftCreate_RequestReceiptAddsHeader verifies build raw EML for draft create request receipt adds header. +func TestBuildRawEMLForDraftCreate_RequestReceiptAddsHeader(t *testing.T) { + input := draftCreateInput{ + From: "sender@example.com", + Subject: "needs receipt", + Body: "

hi

", + } + + rawEML, err := buildRawEMLForDraftCreate(context.Background(), + newRuntimeWithFromAndRequestReceipt("sender@example.com", true), input, nil, "") + if err != nil { + t.Fatalf("buildRawEMLForDraftCreate() error = %v", err) + } + eml := decodeBase64URL(rawEML) + + // Pin the full header value, not just "sender@example.com" somewhere in the + // EML — the From: header already contains that address, so a substring + // check would pass even if the DNT wiring was completely broken. + if !strings.Contains(eml, "Disposition-Notification-To: ") { + t.Errorf("expected DNT header addressed to sender; got EML:\n%s", eml) + } +} + +// TestBuildRawEMLForDraftCreate_RequestReceiptOmittedByDefault verifies build raw EML for draft create request receipt omitted by default. +func TestBuildRawEMLForDraftCreate_RequestReceiptOmittedByDefault(t *testing.T) { + input := draftCreateInput{ + From: "sender@example.com", + Subject: "no receipt", + Body: "

hi

", + } + + rawEML, err := buildRawEMLForDraftCreate(context.Background(), + newRuntimeWithFromAndRequestReceipt("sender@example.com", false), input, nil, "") + if err != nil { + t.Fatalf("buildRawEMLForDraftCreate() error = %v", err) + } + eml := decodeBase64URL(rawEML) + + if strings.Contains(eml, "Disposition-Notification-To:") { + t.Errorf("expected no Disposition-Notification-To header when --request-receipt unset; got EML:\n%s", eml) + } +} + +// TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve verifies build raw EML for draft create plain text skips resolve. func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) { chdirTemp(t) os.WriteFile("img.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) @@ -201,6 +269,7 @@ func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) { } } +// TestMailDraftCreatePrettyOutputsReference verifies mail draft create pretty outputs reference. func TestMailDraftCreatePrettyOutputsReference(t *testing.T) { f, stdout, _, reg := mailShortcutTestFactory(t) diff --git a/shortcuts/mail/mail_draft_edit.go b/shortcuts/mail/mail_draft_edit.go index 013d03632..4d09f9ede 100644 --- a/shortcuts/mail/mail_draft_edit.go +++ b/shortcuts/mail/mail_draft_edit.go @@ -15,6 +15,9 @@ import ( draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" ) +// MailDraftEdit is the `+draft-edit` shortcut: update an existing draft +// without sending it. Performs MIME-safe read/patch/write so unchanged +// structure, attachments, and headers are preserved where possible. var MailDraftEdit = common.Shortcut{ Service: "mail", Command: "+draft-edit", @@ -35,6 +38,7 @@ var MailDraftEdit = common.Shortcut{ {Name: "print-patch-template", Type: "bool", Desc: "Print the JSON template and supported operations for the --patch-file flag. Recommended first step before generating a patch file. No draft read or write is performed."}, {Name: "set-priority", Desc: "Set email priority: high, normal, low. Setting 'normal' removes any existing priority header."}, {Name: "inspect", Type: "bool", Desc: "Inspect the draft without modifying it. Returns the draft projection including subject, recipients, body summary, has_quoted_content (whether the draft contains a reply/forward quote block), attachments_summary (with part_id and cid for each attachment), and inline_summary. Run this BEFORE editing body to check has_quoted_content: if true, use set_reply_body in --patch-file to preserve the quote; if false, use set_body."}, + {Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the draft's sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed. Adds the Disposition-Notification-To header; existing value is overwritten."}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { if runtime.Bool("print-patch-template") { @@ -99,6 +103,25 @@ var MailDraftEdit = common.Shortcut{ if len(snapshot.From) > 0 { draftFromEmail = snapshot.From[0].Address } + if err := requireSenderForRequestReceipt(runtime, draftFromEmail); err != nil { + return err + } + if runtime.Bool("request-receipt") { + // draftFromEmail comes from the existing draft's From header, + // which could have been authored via a raw-EML path (IMAP APPEND, + // OpenAPI drafts raw) and contain CR/LF or dangerous Unicode. + // Going straight into PatchOp.Value would bypass emlbuilder's + // validateHeaderValue gate, so repeat the check here explicitly. + if err := validateHeaderAddress(draftFromEmail); err != nil { + return output.ErrValidation( + "cannot set --request-receipt: draft From address is unsafe for a header (%v)", err) + } + patch.Ops = append(patch.Ops, draftpkg.PatchOp{ + Op: "set_header", + Name: "Disposition-Notification-To", + Value: "<" + draftFromEmail + ">", + }) + } for i := range patch.Ops { if patch.Ops[i].Op == "insert_signature" { sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail) @@ -173,6 +196,10 @@ var MailDraftEdit = common.Shortcut{ }, } +// executeDraftInspect implements the +draft-edit --inspect path: it fetches +// the raw EML, parses it into a MIME snapshot, and emits a draft projection +// (subject, recipients, body summary, attachment / inline summaries) without +// modifying the draft. func executeDraftInspect(runtime *common.RuntimeContext, mailboxID, draftID string) error { rawDraft, err := draftpkg.GetRaw(runtime, mailboxID, draftID) if err != nil { @@ -236,6 +263,8 @@ func executeDraftInspect(runtime *common.RuntimeContext, mailboxID, draftID stri return nil } +// prettyDraftAddresses renders a list of draft addresses as a comma-separated +// string suitable for stderr human output. Returns "" for an empty list. func prettyDraftAddresses(addrs []draftpkg.Address) string { if len(addrs) == 0 { return "" @@ -247,6 +276,11 @@ func prettyDraftAddresses(addrs []draftpkg.Address) string { return strings.Join(parts, ", ") } +// buildDraftEditPatch assembles a draftpkg.Patch from the runtime flags: +// direct flags (--set-subject / --set-to / --set-cc / --set-bcc / +// --set-priority) become Ops, and --patch-file is loaded and merged. +// Returns ErrValidation when neither direct flags nor --patch-file produce +// any operations. func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error) { patch := draftpkg.Patch{ Options: draftpkg.PatchOptions{ @@ -312,12 +346,22 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error) } } - if len(patch.Ops) == 0 { + if len(patch.Ops) == 0 && !runtime.Bool("request-receipt") { return patch, output.ErrValidation("at least one edit operation is required; use direct flags such as --set-subject/--set-to, or use --patch-file for body edits and other advanced operations (run --print-patch-template first)") } + if len(patch.Ops) == 0 { + // --request-receipt only: Validate() would reject empty Ops, so skip it + // here. The Disposition-Notification-To op is appended in Execute once + // the draft's From address is known. + return patch, nil + } return patch, patch.Validate() } +// loadPatchFile reads and JSON-decodes a patch file from a relative path +// rooted at the runtime's FileIO. Returns ErrValidation on read or parse +// errors so the caller can surface a user-friendly message without leaking +// internal stack traces. func loadPatchFile(runtime *common.RuntimeContext, path string) (draftpkg.Patch, error) { var patch draftpkg.Patch f, err := runtime.FileIO().Open(path) @@ -335,6 +379,9 @@ func loadPatchFile(runtime *common.RuntimeContext, path string) (draftpkg.Patch, return patch, patch.Validate() } +// buildDraftEditPatchTemplate returns the JSON template emitted by +// --print-patch-template. It documents the supported ops and field shapes so +// callers can author a --patch-file without having to read this file's source. func buildDraftEditPatchTemplate() map[string]interface{} { return map[string]interface{}{ "description": "Typed patch JSON for `mail +draft-edit --patch-file`. This is not RFC 6902 JSON Patch.", diff --git a/shortcuts/mail/mail_forward.go b/shortcuts/mail/mail_forward.go index 636cb3371..f4a3cbfcd 100644 --- a/shortcuts/mail/mail_forward.go +++ b/shortcuts/mail/mail_forward.go @@ -16,6 +16,9 @@ import ( "github.com/larksuite/cli/shortcuts/mail/emlbuilder" ) +// MailForward is the `+forward` shortcut: forward an existing message to +// new recipients, saving a draft by default (or sending immediately with +// --confirm-send). Original message block is included automatically. var MailForward = common.Shortcut{ Service: "mail", Command: "+forward", @@ -36,6 +39,7 @@ var MailForward = common.Shortcut{ {Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."}, {Name: "confirm-send", Type: "bool", Desc: "Send the forward immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."}, {Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."}, + {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."}, signatureFlag, priorityFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -110,7 +114,16 @@ var MailForward = common.Shortcut{ } orig := sourceMsg.Original - senderEmail := resolveComposeSenderEmail(runtime) + resolvedSender := resolveComposeSenderEmail(runtime) + // Check --request-receipt BEFORE the orig.headTo fallback below: + // the receipt's Disposition-Notification-To must point to an address + // the caller explicitly controls, not to a fallback picked from the + // original mail's headers (which may belong to someone else when the + // mailbox is only on CC or in a shared-mailbox scenario). + if err := requireSenderForRequestReceipt(runtime, resolvedSender); err != nil { + return err + } + senderEmail := resolvedSender if senderEmail == "" { senderEmail = orig.headTo } @@ -125,6 +138,12 @@ var MailForward = common.Shortcut{ if senderEmail != "" { bld = bld.From("", senderEmail) } + // Note: requireSenderForRequestReceipt already ran above against + // resolvedSender (pre-fallback). When --request-receipt is set we + // are guaranteed resolvedSender != "", so senderEmail == resolvedSender. + if runtime.Bool("request-receipt") { + bld = bld.DispositionNotificationTo("", senderEmail) + } if ccFlag != "" { bld = bld.CCAddrs(parseNetAddrs(ccFlag)) } diff --git a/shortcuts/mail/mail_message.go b/shortcuts/mail/mail_message.go index f5045be81..86fabb51e 100644 --- a/shortcuts/mail/mail_message.go +++ b/shortcuts/mail/mail_message.go @@ -10,6 +10,8 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) +// MailMessage is the `+message` shortcut: fetch full content of a single +// email by message ID (normalized body + attachments / inline metadata). var MailMessage = common.Shortcut{ Service: "mail", Command: "+message", @@ -48,6 +50,7 @@ var MailMessage = common.Shortcut{ out := buildMessageOutput(msg, html) runtime.Out(out, nil) + maybeHintReadReceiptRequest(runtime, mailboxID, messageID, msg) return nil }, } diff --git a/shortcuts/mail/mail_messages.go b/shortcuts/mail/mail_messages.go index 10cd33298..444aa105f 100644 --- a/shortcuts/mail/mail_messages.go +++ b/shortcuts/mail/mail_messages.go @@ -10,12 +10,16 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) +// mailMessagesOutput is the +messages JSON output: the batch-get result, +// plus the total count and any requested IDs the backend did not return. type mailMessagesOutput struct { Messages []map[string]interface{} `json:"messages"` Total int `json:"total"` UnavailableMessageIDs []string `json:"unavailable_message_ids,omitempty"` } +// MailMessages is the `+messages` shortcut: batch-fetch full content for +// up to 20 message IDs in a single call, preserving request order. var MailMessages = common.Shortcut{ Service: "mail", Command: "+messages", @@ -73,6 +77,9 @@ var MailMessages = common.Shortcut{ Total: len(messages), UnavailableMessageIDs: missingMessageIDs, }, nil) + for _, msg := range rawMessages { + maybeHintReadReceiptRequest(runtime, mailboxID, strVal(msg["message_id"]), msg) + } return nil }, } diff --git a/shortcuts/mail/mail_reply.go b/shortcuts/mail/mail_reply.go index f70d9e842..3cdab22dd 100644 --- a/shortcuts/mail/mail_reply.go +++ b/shortcuts/mail/mail_reply.go @@ -13,6 +13,9 @@ import ( "github.com/larksuite/cli/shortcuts/mail/emlbuilder" ) +// MailReply is the `+reply` shortcut: reply to the sender of a message, +// saving a draft by default (or sending immediately with --confirm-send). +// Automatically sets Re: subject, In-Reply-To, and References headers. var MailReply = common.Shortcut{ Service: "mail", Command: "+reply", @@ -33,6 +36,7 @@ var MailReply = common.Shortcut{ {Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."}, {Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."}, {Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."}, + {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."}, signatureFlag, priorityFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -104,7 +108,16 @@ var MailReply = common.Shortcut{ orig := sourceMsg.Original stripLargeAttachmentCard(&orig) - senderEmail := resolveComposeSenderEmail(runtime) + resolvedSender := resolveComposeSenderEmail(runtime) + // Check --request-receipt BEFORE the orig.headTo fallback below: + // the receipt's Disposition-Notification-To must point to an address + // the caller explicitly controls, not to a fallback picked from the + // original mail's headers (which may belong to someone else when the + // mailbox is only on CC or in a shared-mailbox scenario). + if err := requireSenderForRequestReceipt(runtime, resolvedSender); err != nil { + return err + } + senderEmail := resolvedSender if senderEmail == "" { senderEmail = orig.headTo } @@ -136,6 +149,12 @@ var MailReply = common.Shortcut{ if senderEmail != "" { bld = bld.From("", senderEmail) } + // Note: requireSenderForRequestReceipt already ran above against + // resolvedSender (pre-fallback). When --request-receipt is set we + // are guaranteed resolvedSender != "", so senderEmail == resolvedSender. + if runtime.Bool("request-receipt") { + bld = bld.DispositionNotificationTo("", senderEmail) + } if ccFlag != "" { bld = bld.CCAddrs(parseNetAddrs(ccFlag)) } diff --git a/shortcuts/mail/mail_reply_all.go b/shortcuts/mail/mail_reply_all.go index 7be390a90..5ba75c8c7 100644 --- a/shortcuts/mail/mail_reply_all.go +++ b/shortcuts/mail/mail_reply_all.go @@ -13,6 +13,9 @@ import ( "github.com/larksuite/cli/shortcuts/mail/emlbuilder" ) +// MailReplyAll is the `+reply-all` shortcut: reply to the sender plus all +// recipients of a message (with address dedup and self-exclusion), saving a +// draft by default (or sending immediately with --confirm-send). var MailReplyAll = common.Shortcut{ Service: "mail", Command: "+reply-all", @@ -34,6 +37,7 @@ var MailReplyAll = common.Shortcut{ {Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."}, {Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."}, {Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."}, + {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."}, signatureFlag, priorityFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -106,7 +110,16 @@ var MailReplyAll = common.Shortcut{ orig := sourceMsg.Original stripLargeAttachmentCard(&orig) - senderEmail := resolveComposeSenderEmail(runtime) + resolvedSender := resolveComposeSenderEmail(runtime) + // Check --request-receipt BEFORE the orig.headTo fallback below: + // the receipt's Disposition-Notification-To must point to an address + // the caller explicitly controls, not to a fallback picked from the + // original mail's headers (which may belong to someone else in a + // shared-mailbox / multi-recipient scenario). + if err := requireSenderForRequestReceipt(runtime, resolvedSender); err != nil { + return err + } + senderEmail := resolvedSender if senderEmail == "" { senderEmail = orig.headTo } @@ -150,6 +163,12 @@ var MailReplyAll = common.Shortcut{ if senderEmail != "" { bld = bld.From("", senderEmail) } + // Note: requireSenderForRequestReceipt already ran above against + // resolvedSender (pre-fallback). When --request-receipt is set we + // are guaranteed resolvedSender != "", so senderEmail == resolvedSender. + if runtime.Bool("request-receipt") { + bld = bld.DispositionNotificationTo("", senderEmail) + } if ccList != "" { bld = bld.CCAddrs(parseNetAddrs(ccList)) } diff --git a/shortcuts/mail/mail_request_receipt_integration_test.go b/shortcuts/mail/mail_request_receipt_integration_test.go new file mode 100644 index 000000000..32d6f76ce --- /dev/null +++ b/shortcuts/mail/mail_request_receipt_integration_test.go @@ -0,0 +1,396 @@ +// 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": "", + "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: ") { + 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: ") { + 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: ") { + 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: ") { + 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) + } +} diff --git a/shortcuts/mail/mail_send.go b/shortcuts/mail/mail_send.go index dd36ba4e4..418c9654c 100644 --- a/shortcuts/mail/mail_send.go +++ b/shortcuts/mail/mail_send.go @@ -13,6 +13,8 @@ import ( "github.com/larksuite/cli/shortcuts/mail/emlbuilder" ) +// MailSend is the `+send` shortcut: compose a new email and save it as a +// draft by default (or send immediately with --confirm-send). var MailSend = common.Shortcut{ Service: "mail", Command: "+send", @@ -33,6 +35,7 @@ var MailSend = common.Shortcut{ {Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."}, {Name: "confirm-send", Type: "bool", Desc: "Send the email immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."}, {Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."}, + {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."}, signatureFlag, priorityFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -106,6 +109,12 @@ var MailSend = common.Shortcut{ if senderEmail != "" { bld = bld.From("", senderEmail) } + if err := requireSenderForRequestReceipt(runtime, senderEmail); err != nil { + return err + } + if runtime.Bool("request-receipt") { + bld = bld.DispositionNotificationTo("", senderEmail) + } if ccFlag != "" { bld = bld.CCAddrs(parseNetAddrs(ccFlag)) } diff --git a/shortcuts/mail/mail_send_receipt.go b/shortcuts/mail/mail_send_receipt.go new file mode 100644 index 000000000..ddce2b795 --- /dev/null +++ b/shortcuts/mail/mail_send_receipt.go @@ -0,0 +1,363 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/larksuite/cli/shortcuts/common" + draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" + "github.com/larksuite/cli/shortcuts/mail/emlbuilder" +) + +// readReceiptRequestLabel is the system label applied to incoming messages +// that carry a Disposition-Notification-To header (SystemLabelReadReceiptRequest=-607). +const readReceiptRequestLabel = "READ_RECEIPT_REQUEST" + +// receiptMetaLabelSet groups the localized strings used by the auto-generated +// receipt Subject and body. Mirrors the quoteMetaLabelSet pattern in +// mail_quote.go used by reply / forward. +// +// Labels bake their trailing punctuation (":" / ": ") in so that callers can +// concatenate without language-specific logic. +type receiptMetaLabelSet struct { + SubjectPrefix string // "已读回执:" / "Read receipt: " + Lead string // first-line statement in the receipt body + Subject string // label for the original mail subject + To string // label for the original mail's recipient (= the mailbox reading it, which is also the From of the outgoing receipt). This field is the LABEL rendered in the receipt body's quote block — the receipt's envelope recipient (original sender) is set separately via emlbuilder's To() call. + Sent string // label for the original send time + Read string // label for the current read time (when the receipt was generated) +} + +// receiptMetaLabels returns the zh / en label set; "zh" is selected when +// detectSubjectLang finds CJK content. Matches the CLI-wide convention set by +// mail_quote.go:quoteMetaLabels — zh / en only, driven by the original subject. +func receiptMetaLabels(lang string) receiptMetaLabelSet { + if lang == "zh" { + return receiptMetaLabelSet{ + SubjectPrefix: "已读回执:", + Lead: "您发送的邮件已被阅读,详情如下:", + Subject: "主题:", + To: "收件人:", + Sent: "发送时间:", + Read: "阅读时间:", + } + } + return receiptMetaLabelSet{ + SubjectPrefix: "Read receipt: ", + Lead: "Your message has been read. Details:", + Subject: "Subject: ", + To: "To: ", + Sent: "Sent: ", + Read: "Read: ", + } +} + +// MailSendReceipt is the `+send-receipt` shortcut: send an auto-generated +// read-receipt reply (RFC 3798 MDN) for an incoming message that carries +// the READ_RECEIPT_REQUEST label. Risk is "high-risk-write"; callers must +// pass --yes. +var MailSendReceipt = common.Shortcut{ + Service: "mail", + Command: "+send-receipt", + Description: "Send a read-receipt reply for an incoming message that requested one (i.e. carries the READ_RECEIPT_REQUEST label). Body is auto-generated (subject / recipient / send time / read time) to match the Lark client's receipt format — callers cannot customize it, matching the industry norm that read-receipt bodies are system-generated templates, not free-form replies. Intended for agent use after the user confirms.", + Risk: "high-risk-write", + Scopes: []string{ + "mail:user_mailbox.message:send", + "mail:user_mailbox.message:modify", + "mail:user_mailbox.message:readonly", + "mail:user_mailbox:readonly", + "mail:user_mailbox.message.address:read", + "mail:user_mailbox.message.subject:read", + // +send-receipt doesn't read the body content itself, but + // fetchFullMessage(..., false) uses format=plain_text_full which + // the backend scope-checks against body:read. Declared explicitly + // to keep the static Scopes truthful and aligned with +triage / + // +message / +thread which all list this scope. + "mail:user_mailbox.message.body:read", + }, + AuthTypes: []string{"user"}, + Flags: []common.Flag{ + {Name: "message-id", Desc: "Required. Message ID of the incoming mail that requested a read receipt.", Required: true}, + {Name: "mailbox", Desc: "Mailbox email address that owns the receipt reply (default: me)."}, + {Name: "from", Desc: "Sender email address for the From header. Defaults to the mailbox's primary address."}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + messageID := runtime.Str("message-id") + mailboxID := resolveComposeMailboxID(runtime) + return common.NewDryRunAPI(). + Desc("Send read receipt: fetch the original message → verify the READ_RECEIPT_REQUEST label is present → build a reply with subject \"已读回执:\" (zh) or \"Read receipt: \" (en) picked by CJK detection on the original subject, In-Reply-To / References threading, and X-Lark-Read-Receipt-Mail: 1 → create draft and send. The backend extracts the private header, sets BodyExtra.IsReadReceiptMail, and DraftSend applies the READ_RECEIPT_SENT label to the outgoing message."). + GET(mailboxPath(mailboxID, "messages", messageID)). + Params(map[string]interface{}{"format": messageGetFormat(false)}). + GET(mailboxPath(mailboxID, "profile")). + POST(mailboxPath(mailboxID, "drafts")). + Body(map[string]interface{}{"raw": ""}). + POST(mailboxPath(mailboxID, "drafts", "", "send")) + }, + // No Validate: +send-receipt takes no user-provided content (subject / + // body / recipients are all derived from the original message). The + // :send scope is declared in static Scopes above and pre-checked by + // runner.checkShortcutScopes before Execute runs, so dynamic scope + // validation here would be redundant. Mirrors +send, which also keeps + // :send in static Scopes and skips validateConfirmSendScope. + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + messageID := runtime.Str("message-id") + + mailboxID := resolveComposeMailboxID(runtime) + + msg, err := fetchFullMessage(runtime, mailboxID, messageID, false) + if err != nil { + return fmt.Errorf("failed to fetch original message: %w", err) + } + if !hasReadReceiptRequestLabel(msg) { + return fmt.Errorf("message %s did not request a read receipt (no %s label); refusing to send receipt", messageID, readReceiptRequestLabel) + } + + origSubject := strVal(msg["subject"]) + origSMTPID := normalizeMessageID(strVal(msg["smtp_message_id"])) + origFromEmail, _ := extractAddressPair(msg["head_from"]) + origReferences := joinReferences(msg["references"]) + origSendMillis := parseInternalDateMillis(msg["internal_date"]) + + if origFromEmail == "" { + return fmt.Errorf("original message %s has no sender address; cannot address receipt", messageID) + } + + senderEmail := resolveComposeSenderEmail(runtime) + if senderEmail == "" { + return fmt.Errorf("unable to determine sender email; please specify --from explicitly") + } + + lang := detectSubjectLang(origSubject) + readTime := time.Now() + textBody := buildReceiptTextBody(lang, origSubject, senderEmail, origSendMillis, readTime) + htmlBody := buildReceiptHTMLBody(lang, origSubject, senderEmail, origSendMillis, readTime) + + bld := emlbuilder.New().WithFileIO(runtime.FileIO()). + Subject(buildReceiptSubject(origSubject)). + From("", senderEmail). + To("", origFromEmail). + TextBody([]byte(textBody)). + HTMLBody([]byte(htmlBody)). + IsReadReceiptMail(true) + if origSMTPID != "" { + bld = bld.InReplyTo(origSMTPID) + } + if refs := buildReceiptReferences(origReferences, origSMTPID); refs != "" { + bld = bld.References(refs) + } + if messageID != "" { + bld = bld.LMSReplyToMessageID(messageID) + } + + rawEML, err := bld.BuildBase64URL() + if err != nil { + return fmt.Errorf("failed to build receipt EML: %w", err) + } + + draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML) + if err != nil { + return fmt.Errorf("failed to create receipt draft: %w", err) + } + resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, "") + if err != nil { + return fmt.Errorf("failed to send receipt (draft %s created but not sent): %w", draftResult.DraftID, err) + } + + out := buildDraftSendOutput(resData, mailboxID) + out["receipt_for_message_id"] = messageID + runtime.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintln(w, "已对原邮件发送回执 / Read receipt sent.") + fmt.Fprintf(w, "receipt_for_message_id: %s\n", messageID) + }) + return nil + }, +} + +// hasReadReceiptRequestLabel returns true when the message's label_ids include +// either the symbolic name "READ_RECEIPT_REQUEST" or the numeric system-label +// id "-607" (backends have returned both forms historically). +func hasReadReceiptRequestLabel(msg map[string]interface{}) bool { + labels := toStringList(msg["label_ids"]) + for _, l := range labels { + if l == readReceiptRequestLabel || l == "-607" { + return true + } + } + return false +} + +// maybeHintReadReceiptRequest prints a stderr tip if the just-read message +// carries a read-receipt request. Noop for messages without the label or +// without a resolvable message_id. Called by +message / +messages / +thread +// after primary JSON output so callers and humans both see it. +func maybeHintReadReceiptRequest(runtime *common.RuntimeContext, mailboxID, messageID string, msg map[string]interface{}) { + if messageID == "" || !hasReadReceiptRequestLabel(msg) { + return + } + fromEmail, _ := extractAddressPair(msg["head_from"]) + subject := strVal(msg["subject"]) + hintReadReceiptRequest(runtime, mailboxID, messageID, fromEmail, subject) +} + +// buildReceiptSubject prepends the language-appropriate receipt prefix once. +// Language is detected from the original subject itself, matching +// buildReplySubject / buildForwardSubject in mail_quote.go. +// +// Idempotent: if the subject already starts with a known receipt prefix +// (zh "已读回执:" or en "Read receipt: "), the existing prefix is stripped +// before the language-appropriate one is re-applied. This matters when the +// input is already a receipt (unusual, but not rejected elsewhere) and keeps +// us from producing "Read receipt: 已读回执:..." chains. +// +// NOTE: the backend GetRealSubject regex is driven by TCC +// MailPrefixConfig.SubjectPrefixListForAdvancedSearch — that list must include +// both "已读回执:" and "Read receipt: " for conversation aggregation to work +// across languages. zh was already covered; en requires a TCC update. +func buildReceiptSubject(original string) string { + trimmed := strings.TrimSpace(original) + // Detect language on the ORIGINAL subject so that the prefix we re-apply + // matches the author's intent even when every remaining CJK character + // lives inside a prefix we're about to strip (e.g. "已读回执:已读回执:x" + // → strip both prefixes → "x", but the author obviously wanted zh). + lang := detectSubjectLang(trimmed) + // Strip either known prefix case-insensitively (en), exact (zh). Loop so + // accidental chains ("Read receipt: Read receipt: ...") collapse too. + for { + switch { + case strings.HasPrefix(trimmed, "已读回执:"): + trimmed = strings.TrimSpace(strings.TrimPrefix(trimmed, "已读回执:")) + case strings.HasPrefix(strings.ToLower(trimmed), "read receipt:"): + trimmed = strings.TrimSpace(trimmed[len("read receipt:"):]) + default: + return receiptMetaLabels(lang).SubjectPrefix + trimmed + } + } +} + +// buildReceiptReferences appends the original message's SMTP Message-ID to its +// existing References chain, producing the References header for the receipt. +// Both inputs are optional; the return value is a space-joined list with angle +// brackets, suitable for the emlbuilder References() method. +func buildReceiptReferences(origRefs, origSMTPID string) string { + var parts []string + if trimmed := strings.TrimSpace(origRefs); trimmed != "" { + parts = append(parts, trimmed) + } + if origSMTPID != "" { + parts = append(parts, "<"+origSMTPID+">") + } + return strings.Join(parts, " ") +} + +// extractAddressPair returns (email, name) from the head_from / reply_to / +// entry in the raw /messages response, handling both object and string forms. +func extractAddressPair(v interface{}) (email, name string) { + switch t := v.(type) { + case map[string]interface{}: + email = strVal(t["mail_address"]) + name = strVal(t["name"]) + case string: + email = t + } + return email, name +} + +// parseInternalDateMillis parses the internal_date field from a /messages +// response (which the API returns as a string-encoded Unix millisecond +// timestamp). Returns 0 if the value is missing or unparseable; callers render +// a placeholder in that case rather than erroring. +func parseInternalDateMillis(v interface{}) int64 { + s := strings.TrimSpace(strVal(v)) + if s == "" { + return 0 + } + ms, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0 + } + return ms +} + +// renderReceiptTime formats a millisecond timestamp for display inside the +// receipt body. Returns an empty-safe placeholder when the timestamp is 0. +// Reuses formatMailDate (mail_quote.go) so receipts read the same way as +// the quote block used by +reply / +forward. +func renderReceiptTime(ms int64, lang string) string { + if ms <= 0 { + return "-" + } + return formatMailDate(ms, lang) +} + +// buildReceiptTextBody returns the plain-text body used when a +send-receipt +// sends the auto-generated acknowledgement. The layout mirrors the Lark PC / +// Mobile clients' receipt body: one header line followed by quoted key-value +// lines for subject / recipient / send time / read time. Callers cannot +// customize this body — the Subject field carries the receipt prefix which +// is the semantically meaningful signal; free-form user notes belong in a +// normal +reply instead. +func buildReceiptTextBody(lang, origSubject, origRecipient string, origSendMillis int64, readTime time.Time) string { + labels := receiptMetaLabels(lang) + var b strings.Builder + b.WriteString(labels.Lead) + b.WriteByte('\n') + fmt.Fprintf(&b, "> %s%s\n", labels.Subject, strings.TrimSpace(origSubject)) + fmt.Fprintf(&b, "> %s%s\n", labels.To, origRecipient) + fmt.Fprintf(&b, "> %s%s\n", labels.Sent, renderReceiptTime(origSendMillis, lang)) + fmt.Fprintf(&b, "> %s%s\n", labels.Read, formatMailDate(readTime.UnixMilli(), lang)) + return b.String() +} + +// buildReceiptHTMLBody returns the HTML body for the auto-generated receipt. +// Intentionally simpler than the Lark PC client's HTML (no branded styling, +// no proprietary markers) — just enough structure (leading statement + quoted +// key-value block) to render nicely in any MUA. All user-controlled values go +// through htmlEscape to prevent injection from the original subject / headers. +func buildReceiptHTMLBody(lang, origSubject, origRecipient string, origSendMillis int64, readTime time.Time) string { + labels := receiptMetaLabels(lang) + var b strings.Builder + b.WriteString(`
`) + b.WriteString(`
`) + b.WriteString(htmlEscape(labels.Lead)) + b.WriteString(`
`) + b.WriteString(`
`) + fmt.Fprintf(&b, `
%s %s
`, htmlEscape(labels.Subject), htmlEscape(strings.TrimSpace(origSubject))) + fmt.Fprintf(&b, `
%s %s
`, htmlEscape(labels.To), htmlEscape(origRecipient)) + fmt.Fprintf(&b, `
%s %s
`, htmlEscape(labels.Sent), htmlEscape(renderReceiptTime(origSendMillis, lang))) + fmt.Fprintf(&b, `
%s %s
`, htmlEscape(labels.Read), htmlEscape(formatMailDate(readTime.UnixMilli(), lang))) + b.WriteString(`
`) + b.WriteString(`
`) + return b.String() +} + +// joinReferences flattens the references field from the raw /messages response +// into a single space-separated string (the API returns an array of IDs). +func joinReferences(v interface{}) string { + refs := toStringList(v) + if len(refs) == 0 { + return "" + } + // Ensure each entry is surrounded by angle brackets. + out := make([]string, 0, len(refs)) + for _, r := range refs { + r = strings.TrimSpace(r) + if r == "" { + continue + } + if !strings.HasPrefix(r, "<") { + r = "<" + r + } + if !strings.HasSuffix(r, ">") { + r = r + ">" + } + out = append(out, r) + } + return strings.Join(out, " ") +} diff --git a/shortcuts/mail/mail_send_receipt_test.go b/shortcuts/mail/mail_send_receipt_test.go new file mode 100644 index 000000000..b51879e54 --- /dev/null +++ b/shortcuts/mail/mail_send_receipt_test.go @@ -0,0 +1,407 @@ +// 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", " ", "c@x", " "}, + {"only id", "", "c@x", ""}, + {"only refs", "", "", ""}, + {"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", + ` evil & "quoted"`, + `evil">@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{"