Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions shortcuts/mail/emlbuilder/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Subject sets the Subject header.
// Non-ASCII characters are automatically RFC 2047 B-encoded.
// Returns an error builder if subject contains CR or LF.
Expand Down Expand Up @@ -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) ':'.
Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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...)
Expand Down
182 changes: 182 additions & 0 deletions shortcuts/mail/emlbuilder/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" <alice@example.com>`
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 := "<alice@example.com>, <carol@example.com>"
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 "<alice@x.com\r\nX-Injected: 1>"
// 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) {
Expand Down
49 changes: 49 additions & 0 deletions shortcuts/mail/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,55 @@ 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.
//
// Flag values are wrapped in single quotes on the suggested command line so
// that values containing shell metacharacters (spaces, $, &, etc.) stay
// intact when the user copies and pastes the line into a shell. The embedded
// values are also sanitized to strip ANSI / control bytes, and any literal
// single quote is escaped as '\'' to keep the surrounding quoting balanced.
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",
sanitizeForTerminal(fromEmail), sanitizeForTerminal(subject),
shellQuoteForHint(mailboxID), shellQuoteForHint(messageID))
}

// shellQuoteForHint returns s sanitized for 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(sanitizeForTerminal(s), "'", `'\''`)
Comment on lines +70 to +87
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Strip LF before embedding untrusted values in the receipt hint.

sanitizeForTerminal removes CR but preserves \n, so a malicious fromEmail, subject, mailbox ID, or message ID can inject extra terminal lines into this security-sensitive prompt. Use a single-line sanitizer for hint text and shell-quoted arguments.

Proposed fix
 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",
-		sanitizeForTerminal(fromEmail), sanitizeForTerminal(subject),
+		sanitizeSingleLineForHint(fromEmail), sanitizeSingleLineForHint(subject),
 		shellQuoteForHint(mailboxID), shellQuoteForHint(messageID))
 }
 
+func sanitizeSingleLineForHint(s string) string {
+	return strings.ReplaceAll(sanitizeForTerminal(s), "\n", " ")
+}
+
 // shellQuoteForHint returns s sanitized for 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(sanitizeForTerminal(s), "'", `'\''`)
+	return strings.ReplaceAll(sanitizeSingleLineForHint(s), "'", `'\''`)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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",
sanitizeForTerminal(fromEmail), sanitizeForTerminal(subject),
shellQuoteForHint(mailboxID), shellQuoteForHint(messageID))
}
// shellQuoteForHint returns s sanitized for 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(sanitizeForTerminal(s), "'", `'\''`)
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",
sanitizeSingleLineForHint(fromEmail), sanitizeSingleLineForHint(subject),
shellQuoteForHint(mailboxID), shellQuoteForHint(messageID))
}
func sanitizeSingleLineForHint(s string) string {
return strings.ReplaceAll(sanitizeForTerminal(s), "\n", " ")
}
// shellQuoteForHint returns s sanitized for 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(sanitizeSingleLineForHint(s), "'", `'\''`)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/helpers.go` around lines 70 - 87, The hint can be
line-injected because sanitizeForTerminal preserves '\n'; update
hintReadReceiptRequest and shellQuoteForHint to use a single-line sanitizer
(either add a new singleLineSanitize function or extend sanitizeForTerminal)
that strips or replaces LF characters before further processing so untrusted
fromEmail, subject, mailboxID and messageID cannot inject new lines; then pass
singleLineSanitize(...) into hintReadReceiptRequest's format arguments and into
shellQuoteForHint (which should call singleLineSanitize before replacing single
quotes) so the embedded hint and shell-quoted payload are always single-line and
safe.

}

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

// 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) {
Expand Down
Loading
Loading