diff --git a/shortcuts/mail/draft/service.go b/shortcuts/mail/draft/service.go index 9c0cf4202..c27bb2d27 100644 --- a/shortcuts/mail/draft/service.go +++ b/shortcuts/mail/draft/service.go @@ -42,6 +42,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 +62,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 +83,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 != "" { diff --git a/shortcuts/mail/emlbuilder/builder.go b/shortcuts/mail/emlbuilder/builder.go index 2b07637de..de0196845 100644 --- a/shortcuts/mail/emlbuilder/builder.go +++ b/shortcuts/mail/emlbuilder/builder.go @@ -79,6 +79,7 @@ type Builder struct { cc []mail.Address bcc []mail.Address replyTo []mail.Address + dispositionNotificationTo []mail.Address subject string date time.Time messageID string @@ -92,6 +93,7 @@ type Builder struct { 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 } @@ -290,6 +292,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 +599,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 +706,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 +773,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..91247773d 100644 --- a/shortcuts/mail/emlbuilder/builder_test.go +++ b/shortcuts/mail/emlbuilder/builder_test.go @@ -276,6 +276,188 @@ func TestBuild_LMSReplyToMessageID_NotWrittenWithoutInReplyTo(t *testing.T) { } } +// ── Disposition-Notification-To (read receipt) ─────────────────────────────── + +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) + } +} + +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) + } +} + +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) + } +} + +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) + } +} + +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) + } +} + +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) + } +} + +func TestBuild_DispositionNotificationTo_AddrBidiRejected(t *testing.T) { + // Bidi overrides enable visual spoofing (e.g. gmail‮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‮il.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) ────────────────── + +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) + } +} + +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) + } +} + +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) + } +} + // ── CC / BCC ────────────────────────────────────────────────────────────────── func TestBuild_CCBCC(t *testing.T) { diff --git a/shortcuts/mail/helpers.go b/shortcuts/mail/helpers.go index 03ee79b11..4955fedbb 100644 --- a/shortcuts/mail/helpers.go +++ b/shortcuts/mail/helpers.go @@ -54,6 +54,79 @@ 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-send; ask the user first (from=%s, subject=%q)\n"+ + " - if the user confirms, respond with:\n"+ + " lark-cli mail +send-receipt --mailbox '%s' --message-id '%s' --yes\n", + sanitizeForSingleLine(fromEmail), sanitizeForSingleLine(subject), + 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. +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 --from explicitly") + } + 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) { @@ -1604,7 +1677,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 +1697,15 @@ 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", "") +} + func toAddressObject(v interface{}) mailAddressOutput { if m, ok := v.(map[string]interface{}); ok { return mailAddressOutput{Email: strVal(m["mail_address"]), Name: strVal(m["name"])} @@ -1917,6 +2002,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 diff --git a/shortcuts/mail/helpers_test.go b/shortcuts/mail/helpers_test.go index 291cc6b3b..61474a39e 100644 --- a/shortcuts/mail/helpers_test.go +++ b/shortcuts/mail/helpers_test.go @@ -1260,3 +1260,126 @@ 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} +} + +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) + } + }) + } +} + +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) + } + }) + } +} + +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‮b", "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) + } + }) + } +} + +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‮", "dangerous Unicode"}, + {"zero-width rejected", "ali​ce@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_draft_create.go b/shortcuts/mail/mail_draft_create.go index 2900c9bfc..675439250 100644 --- a/shortcuts/mail/mail_draft_create.go +++ b/shortcuts/mail/mail_draft_create.go @@ -46,6 +46,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, }, @@ -142,6 +143,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 +171,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..6fbf93c11 100644 --- a/shortcuts/mail/mail_draft_create_test.go +++ b/shortcuts/mail/mail_draft_create_test.go @@ -178,6 +178,65 @@ 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} +} + +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) + + // Case-insensitive header match — net/mail uses canonical casing on write. + if !strings.Contains(eml, "Disposition-Notification-To:") { + t.Errorf("expected Disposition-Notification-To header when --request-receipt set; got EML:\n%s", eml) + } + if !strings.Contains(eml, "sender@example.com") { + t.Errorf("expected receipt address to be the sender; got EML:\n%s", eml) + } +} + +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) + } +} + func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) { chdirTemp(t) os.WriteFile("img.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644) diff --git a/shortcuts/mail/mail_draft_edit.go b/shortcuts/mail/mail_draft_edit.go index 013d03632..7e4594b18 100644 --- a/shortcuts/mail/mail_draft_edit.go +++ b/shortcuts/mail/mail_draft_edit.go @@ -35,6 +35,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 +100,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) @@ -312,9 +332,15 @@ 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() } diff --git a/shortcuts/mail/mail_forward.go b/shortcuts/mail/mail_forward.go index 636cb3371..beaf5fb33 100644 --- a/shortcuts/mail/mail_forward.go +++ b/shortcuts/mail/mail_forward.go @@ -36,6 +36,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 { @@ -125,6 +126,12 @@ var MailForward = 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_message.go b/shortcuts/mail/mail_message.go index f5045be81..6593767d9 100644 --- a/shortcuts/mail/mail_message.go +++ b/shortcuts/mail/mail_message.go @@ -48,6 +48,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..45565482a 100644 --- a/shortcuts/mail/mail_messages.go +++ b/shortcuts/mail/mail_messages.go @@ -73,6 +73,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..a3e4627b8 100644 --- a/shortcuts/mail/mail_reply.go +++ b/shortcuts/mail/mail_reply.go @@ -33,6 +33,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 { @@ -136,6 +137,12 @@ var MailReply = 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.go b/shortcuts/mail/mail_send.go index dd36ba4e4..1237ecc82 100644 --- a/shortcuts/mail/mail_send.go +++ b/shortcuts/mail/mail_send.go @@ -33,6 +33,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 +107,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..4112886b5 --- /dev/null +++ b/shortcuts/mail/mail_send_receipt.go @@ -0,0 +1,349 @@ +// 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 address the receipt is sent to (= original mail's sender) + 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: ", + } +} + +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", + }, + 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)). + GET(mailboxPath(mailboxID, "profile")). + POST(mailboxPath(mailboxID, "drafts")). + Body(map[string]interface{}{"raw": ""}). + POST(mailboxPath(mailboxID, "drafts", "", "send")) + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateConfirmSendScope(runtime) + }, + 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..18cb011da --- /dev/null +++ b/shortcuts/mail/mail_send_receipt_test.go @@ -0,0 +1,388 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "strings" + "testing" + "time" +) + +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) + } + }) + } +} + +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) + } +} + +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 re-detected after strip; once CJK is stripped en prefix wins. + {"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) + } + } +} + +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) + } + }) + } +} + +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) + } +} + +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-send", + "alice@example.com", + "weekly report", + "+send-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()) + } + }) +} + +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) + } + }) + } +} + +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) + } +} + +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) + } + } +} + +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) + } + } +} + +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) + } +} + +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{"