Skip to content

Commit af6e318

Browse files
committed
feat: add read receipt support
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.
1 parent e6f3fa2 commit af6e318

31 files changed

Lines changed: 2591 additions & 31 deletions

shortcuts/mail/draft/service.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import (
1111
"github.com/larksuite/cli/shortcuts/common"
1212
)
1313

14+
// mailboxPath joins mailboxID and the given segments under the
15+
// /open-apis/mail/v1/user_mailboxes/ root, URL-escaping each component.
16+
// Empty segments are skipped.
1417
func mailboxPath(mailboxID string, segments ...string) string {
1518
parts := make([]string, 0, len(segments)+1)
1619
parts = append(parts, url.PathEscape(mailboxID))
@@ -23,6 +26,10 @@ func mailboxPath(mailboxID string, segments ...string) string {
2326
return "/open-apis/mail/v1/user_mailboxes/" + strings.Join(parts, "/")
2427
}
2528

29+
// GetRaw fetches the raw EML of a draft via drafts.get(format=raw) and
30+
// returns the draft ID alongside the EML. If the backend response omits
31+
// draft_id, the input draftID is echoed back so callers always have a
32+
// non-empty identifier to round-trip.
2633
func GetRaw(runtime *common.RuntimeContext, mailboxID, draftID string) (DraftRaw, error) {
2734
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "drafts", draftID), map[string]interface{}{"format": "raw"}, nil)
2835
if err != nil {
@@ -42,6 +49,11 @@ func GetRaw(runtime *common.RuntimeContext, mailboxID, draftID string) (DraftRaw
4249
}, nil
4350
}
4451

52+
// CreateWithRaw creates a draft in mailboxID from a pre-built base64url-encoded
53+
// EML payload and returns the server-assigned draft ID along with the
54+
// optional preview reference URL. Use this when the caller has already
55+
// assembled the EML with emlbuilder; for high-level compose paths use the
56+
// MailDraftCreate shortcut instead.
4557
func CreateWithRaw(runtime *common.RuntimeContext, mailboxID, rawEML string) (DraftResult, error) {
4658
data, err := runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts"), nil, map[string]interface{}{"raw": rawEML})
4759
if err != nil {
@@ -57,6 +69,12 @@ func CreateWithRaw(runtime *common.RuntimeContext, mailboxID, rawEML string) (Dr
5769
}, nil
5870
}
5971

72+
// UpdateWithRaw overwrites an existing draft's content with a pre-built
73+
// base64url-encoded EML. Existing headers / body / attachments in the draft
74+
// are replaced wholesale; callers that want to patch individual parts should
75+
// use draftpkg.Apply on a parsed snapshot instead. The returned DraftResult
76+
// carries the (possibly re-issued) draft ID and the preview reference URL
77+
// when the backend provides one.
6078
func UpdateWithRaw(runtime *common.RuntimeContext, mailboxID, draftID, rawEML string) (DraftResult, error) {
6179
data, err := runtime.CallAPI("PUT", mailboxPath(mailboxID, "drafts", draftID), nil, map[string]interface{}{"raw": rawEML})
6280
if err != nil {
@@ -72,6 +90,10 @@ func UpdateWithRaw(runtime *common.RuntimeContext, mailboxID, draftID, rawEML st
7290
}, nil
7391
}
7492

93+
// Send dispatches a previously created draft. When sendTime is a non-empty
94+
// Unix-seconds string the backend schedules delivery; otherwise delivery is
95+
// immediate. The returned map is the raw API response body, typically
96+
// including message_id / thread_id / recall_status.
7597
func Send(runtime *common.RuntimeContext, mailboxID, draftID, sendTime string) (map[string]interface{}, error) {
7698
var bodyParams map[string]interface{}
7799
if sendTime != "" {
@@ -80,6 +102,9 @@ func Send(runtime *common.RuntimeContext, mailboxID, draftID, sendTime string) (
80102
return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, bodyParams)
81103
}
82104

105+
// extractDraftID returns the first non-empty draft identifier found in the
106+
// API response. Looks at draft_id / id at the top level, then recurses into a
107+
// nested "draft" object. Returns "" when no identifier is present.
83108
func extractDraftID(data map[string]interface{}) string {
84109
if id, ok := data["draft_id"].(string); ok && strings.TrimSpace(id) != "" {
85110
return strings.TrimSpace(id)
@@ -93,6 +118,9 @@ func extractDraftID(data map[string]interface{}) string {
93118
return ""
94119
}
95120

121+
// extractRawEML returns the base64url-encoded raw EML from the response,
122+
// looking at top-level "raw", a nested "message.raw", or a nested "draft"
123+
// object. Returns "" when no EML is present.
96124
func extractRawEML(data map[string]interface{}) string {
97125
if raw, ok := data["raw"].(string); ok && strings.TrimSpace(raw) != "" {
98126
return strings.TrimSpace(raw)
@@ -108,6 +136,9 @@ func extractRawEML(data map[string]interface{}) string {
108136
return ""
109137
}
110138

139+
// extractReference returns the optional preview "reference" URL from the
140+
// response, recursing into a nested "draft" object when present. Returns ""
141+
// when no reference is present.
111142
func extractReference(data map[string]interface{}) string {
112143
if data == nil {
113144
return ""

shortcuts/mail/emlbuilder/builder.go

Lines changed: 77 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -73,26 +73,28 @@ func readFile(fio fileio.FileIO, path string) ([]byte, error) {
7373
// All setter methods return a copy of the Builder (immutable/fluent style),
7474
// so a base builder can be reused across multiple goroutines safely.
7575
type Builder struct {
76-
fio fileio.FileIO // injected via WithFileIO; must be set before AddFile* calls
77-
from mail.Address
78-
to []mail.Address
79-
cc []mail.Address
80-
bcc []mail.Address
81-
replyTo []mail.Address
82-
subject string
83-
date time.Time
84-
messageID string
85-
inReplyTo string // raw value, without angle brackets
86-
references string // space-separated list of message IDs, with angle brackets
87-
lmsReplyToMessageID string // Lark internal message_id of the original message
88-
textBody []byte
89-
htmlBody []byte
90-
calendarBody []byte
91-
attachments []attachment
92-
inlines []inline
93-
extraHeaders [][2]string // ordered list of [name, value] pairs
94-
allowNoRecipients bool // when true, Build() skips the recipient check (for drafts)
95-
err error
76+
fio fileio.FileIO // injected via WithFileIO; must be set before AddFile* calls
77+
from mail.Address
78+
to []mail.Address
79+
cc []mail.Address
80+
bcc []mail.Address
81+
replyTo []mail.Address
82+
dispositionNotificationTo []mail.Address
83+
subject string
84+
date time.Time
85+
messageID string
86+
inReplyTo string // raw value, without angle brackets
87+
references string // space-separated list of message IDs, with angle brackets
88+
lmsReplyToMessageID string // Lark internal message_id of the original message
89+
textBody []byte
90+
htmlBody []byte
91+
calendarBody []byte
92+
attachments []attachment
93+
inlines []inline
94+
extraHeaders [][2]string // ordered list of [name, value] pairs
95+
allowNoRecipients bool // when true, Build() skips the recipient check (for drafts)
96+
isReadReceiptMail bool // when true, Build() writes X-Lark-Read-Receipt-Mail: 1
97+
err error
9698
}
9799

98100
// WithFileIO returns a copy of b with the given FileIO.
@@ -101,6 +103,9 @@ func (b Builder) WithFileIO(fio fileio.FileIO) Builder {
101103
return b
102104
}
103105

106+
// attachment is a regular (non-inline) MIME attachment — bytes plus MIME
107+
// metadata — accumulated on the Builder and serialized under the
108+
// multipart/mixed outer envelope.
104109
type attachment struct {
105110
content []byte
106111
contentType string
@@ -290,6 +295,36 @@ func (b Builder) ReplyTo(name, addr string) Builder {
290295
return cp
291296
}
292297

298+
// DispositionNotificationTo appends an address to the Disposition-Notification-To header,
299+
// which requests a Message Disposition Notification (MDN, read receipt) from the recipient's
300+
// mail user agent (RFC 3798). name may be empty.
301+
//
302+
// Recipients' clients are not obliged to honour this header; user agents commonly prompt
303+
// the recipient, and many silently ignore it.
304+
func (b Builder) DispositionNotificationTo(name, addr string) Builder {
305+
if addr == "" {
306+
return b
307+
}
308+
if b.err != nil {
309+
return b
310+
}
311+
if err := validateDisplayName(name); err != nil {
312+
b.err = err
313+
return b
314+
}
315+
// addr ends up inside mail.Address.String() and written unescaped into
316+
// the Disposition-Notification-To header; validate it the same way as
317+
// other header value inputs to prevent CR/LF header injection and
318+
// visual-spoofing via Bidi / zero-width code points.
319+
if err := validateHeaderValue(addr); err != nil {
320+
b.err = err
321+
return b
322+
}
323+
cp := b.copySlices()
324+
cp.dispositionNotificationTo = append(cp.dispositionNotificationTo, mail.Address{Name: name, Address: addr})
325+
return cp
326+
}
327+
293328
// Subject sets the Subject header.
294329
// Non-ASCII characters are automatically RFC 2047 B-encoded.
295330
// Returns an error builder if subject contains CR or LF.
@@ -567,6 +602,21 @@ func (b Builder) AllowNoRecipients() Builder {
567602
return b
568603
}
569604

605+
// IsReadReceiptMail marks this message as a read-receipt response.
606+
// When true, Build() writes the private header "X-Lark-Read-Receipt-Mail: 1",
607+
// which data-access extracts into MailBodyExtra.IsReadReceiptMail on draft
608+
// creation so the subsequent DraftSend applies the READ_RECEIPT_SENT label.
609+
//
610+
// The header is a Lark-internal signal; smtp-out-mail-out is expected to
611+
// strip X-Lark-* private headers before external delivery.
612+
func (b Builder) IsReadReceiptMail(v bool) Builder {
613+
if b.err != nil {
614+
return b
615+
}
616+
b.isReadReceiptMail = v
617+
return b
618+
}
619+
570620
// Header appends an extra header to the message.
571621
// Multiple calls with the same name result in multiple header lines.
572622
// Returns an error builder if name or value contains CR, LF, or (for names) ':'.
@@ -659,6 +709,12 @@ func (b Builder) Build() ([]byte, error) {
659709
if len(b.replyTo) > 0 {
660710
writeHeader(&buf, "Reply-To", joinAddresses(b.replyTo))
661711
}
712+
if len(b.dispositionNotificationTo) > 0 {
713+
writeHeader(&buf, "Disposition-Notification-To", joinAddresses(b.dispositionNotificationTo))
714+
}
715+
if b.isReadReceiptMail {
716+
writeHeader(&buf, "X-Lark-Read-Receipt-Mail", "1")
717+
}
662718
if b.inReplyTo != "" {
663719
writeHeader(&buf, "In-Reply-To", "<"+b.inReplyTo+">")
664720
if b.lmsReplyToMessageID != "" {
@@ -720,6 +776,7 @@ func (b Builder) copySlices() Builder {
720776
cp.cc = append([]mail.Address{}, b.cc...)
721777
cp.bcc = append([]mail.Address{}, b.bcc...)
722778
cp.replyTo = append([]mail.Address{}, b.replyTo...)
779+
cp.dispositionNotificationTo = append([]mail.Address{}, b.dispositionNotificationTo...)
723780
cp.attachments = append([]attachment{}, b.attachments...)
724781
cp.inlines = append([]inline{}, b.inlines...)
725782
cp.extraHeaders = append([][2]string{}, b.extraHeaders...)

0 commit comments

Comments
 (0)