mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:55:53 +08:00
End-to-end RFC 3798 Message Disposition Notification support, covering
both sides of the receipt flow — requesting a receipt when composing, and
responding to one (send or decline) when reading.
Request side (compose)
- New --request-receipt flag on +send / +reply / +reply-all / +forward /
+draft-create / +draft-edit. When set, the outgoing EML carries a
Disposition-Notification-To header (RFC 3798) addressed to the resolved
sender. Recipient mail clients may prompt the user, auto-send a receipt,
or silently ignore — delivery is not guaranteed.
- requireSenderForRequestReceipt gates the flag against a controlled
sender address resolved BEFORE the orig.headTo fallback in +reply /
+reply-all / +forward, so the DNT cannot silently land on someone else
in CC / shared-mailbox flows.
Response side
- +send-receipt: build a system-templated reply for messages carrying the
READ_RECEIPT_REQUEST label (-607). Subject / recipient / sent / read
time layout matches the Lark client; body is non-customizable — receipt
bodies are system templates by industry convention; free-form notes
belong in +reply. Risk:"high-risk-write" + --yes required.
- +decline-receipt: clear READ_RECEIPT_REQUEST without sending anything
(mirrors the client's "不发送" / "Don't send" button). Idempotent on
re-run; Risk:"write" — no --yes needed.
Read-path hints
- +message / +messages / +thread emit a stderr hint when surfacing a
mail carrying READ_RECEIPT_REQUEST, exposing BOTH response paths
(+send-receipt --yes / +decline-receipt) so agents present a real
choice to the user instead of silently auto-sending.
Guard rails
- +send / +reply / +reply-all / +forward stay draft-by-default and
require --confirm-send to send, gated by a dynamic scope check for
mail:user_mailbox.message:send (absent from the default scope set so
draft-only flows don't need the sensitive permission).
- All header-bound user input (sender / display name / recipient /
subject) goes through CR/LF rejection plus Bidi / zero-width / line-
separator guards, mirroring emlbuilder.validateHeaderValue, to block
header injection and visual spoofing.
- Hint output strips terminal control characters (CR, LF) from any
untrusted field embedded into the user-visible suggestion.
Backend coupling
- Outgoing receipt EML carries the private header
X-Lark-Read-Receipt-Mail: 1. The data-access backend parses it into
BodyExtra.IsReadReceiptMail; DraftSend then applies READ_RECEIPT_SENT
(-608) and clears READ_RECEIPT_REQUEST (-607) from the original
message, closing the client-side banner.
- en receipts require backend TCC SubjectPrefixListForAdvancedSearch to
include "Read Receipt:" for conversation-view aggregation; zh prefix
("已读回执:") is already configured.
Docs: new reference pages for +send-receipt / +decline-receipt;
--request-receipt noted on each compose-side reference; SKILL.md
workflow (section 9) describes the full privacy-safe decision tree on
both sides.
Tests cover emlbuilder DispositionNotificationTo / IsReadReceiptMail
helpers, receiptMetaLabels (zh / en), buildReceiptSubject, text and HTML
body generators (with HTML escaping and Bidi guards), header-injection
defenses, sender-resolution gating (CC-only / shared-mailbox regression),
hint emission paths, and the full +send-receipt / +decline-receipt happy
+ idempotent paths via httpmock.
364 lines
16 KiB
Go
364 lines
16 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package mail
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/larksuite/cli/shortcuts/common"
|
||
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
||
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
|
||
)
|
||
|
||
// readReceiptRequestLabel is the system label applied to incoming messages
|
||
// that carry a Disposition-Notification-To header (SystemLabelReadReceiptRequest=-607).
|
||
const readReceiptRequestLabel = "READ_RECEIPT_REQUEST"
|
||
|
||
// receiptMetaLabelSet groups the localized strings used by the auto-generated
|
||
// receipt Subject and body. Mirrors the quoteMetaLabelSet pattern in
|
||
// mail_quote.go used by reply / forward.
|
||
//
|
||
// Labels bake their trailing punctuation (":" / ": ") in so that callers can
|
||
// concatenate without language-specific logic.
|
||
type receiptMetaLabelSet struct {
|
||
SubjectPrefix string // "已读回执:" / "Read receipt: "
|
||
Lead string // first-line statement in the receipt body
|
||
Subject string // label for the original mail subject
|
||
To string // label for the original mail's recipient (= the mailbox reading it, which is also the From of the outgoing receipt). This field is the LABEL rendered in the receipt body's quote block — the receipt's envelope recipient (original sender) is set separately via emlbuilder's To() call.
|
||
Sent string // label for the original send time
|
||
Read string // label for the current read time (when the receipt was generated)
|
||
}
|
||
|
||
// receiptMetaLabels returns the zh / en label set; "zh" is selected when
|
||
// detectSubjectLang finds CJK content. Matches the CLI-wide convention set by
|
||
// mail_quote.go:quoteMetaLabels — zh / en only, driven by the original subject.
|
||
func receiptMetaLabels(lang string) receiptMetaLabelSet {
|
||
if lang == "zh" {
|
||
return receiptMetaLabelSet{
|
||
SubjectPrefix: "已读回执:",
|
||
Lead: "您发送的邮件已被阅读,详情如下:",
|
||
Subject: "主题:",
|
||
To: "收件人:",
|
||
Sent: "发送时间:",
|
||
Read: "阅读时间:",
|
||
}
|
||
}
|
||
return receiptMetaLabelSet{
|
||
SubjectPrefix: "Read receipt: ",
|
||
Lead: "Your message has been read. Details:",
|
||
Subject: "Subject: ",
|
||
To: "To: ",
|
||
Sent: "Sent: ",
|
||
Read: "Read: ",
|
||
}
|
||
}
|
||
|
||
// MailSendReceipt is the `+send-receipt` shortcut: send an auto-generated
|
||
// read-receipt reply (RFC 3798 MDN) for an incoming message that carries
|
||
// the READ_RECEIPT_REQUEST label. Risk is "high-risk-write"; callers must
|
||
// pass --yes.
|
||
var MailSendReceipt = common.Shortcut{
|
||
Service: "mail",
|
||
Command: "+send-receipt",
|
||
Description: "Send a read-receipt reply for an incoming message that requested one (i.e. carries the READ_RECEIPT_REQUEST label). Body is auto-generated (subject / recipient / send time / read time) to match the Lark client's receipt format — callers cannot customize it, matching the industry norm that read-receipt bodies are system-generated templates, not free-form replies. Intended for agent use after the user confirms.",
|
||
Risk: "high-risk-write",
|
||
Scopes: []string{
|
||
"mail:user_mailbox.message:send",
|
||
"mail:user_mailbox.message:modify",
|
||
"mail:user_mailbox.message:readonly",
|
||
"mail:user_mailbox:readonly",
|
||
"mail:user_mailbox.message.address:read",
|
||
"mail:user_mailbox.message.subject:read",
|
||
// +send-receipt doesn't read the body content itself, but
|
||
// fetchFullMessage(..., false) uses format=plain_text_full which
|
||
// the backend scope-checks against body:read. Declared explicitly
|
||
// to keep the static Scopes truthful and aligned with +triage /
|
||
// +message / +thread which all list this scope.
|
||
"mail:user_mailbox.message.body:read",
|
||
},
|
||
AuthTypes: []string{"user"},
|
||
Flags: []common.Flag{
|
||
{Name: "message-id", Desc: "Required. Message ID of the incoming mail that requested a read receipt.", Required: true},
|
||
{Name: "mailbox", Desc: "Mailbox email address that owns the receipt reply (default: me)."},
|
||
{Name: "from", Desc: "Sender email address for the From header. Defaults to the mailbox's primary address."},
|
||
},
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
messageID := runtime.Str("message-id")
|
||
mailboxID := resolveComposeMailboxID(runtime)
|
||
return common.NewDryRunAPI().
|
||
Desc("Send read receipt: fetch the original message → verify the READ_RECEIPT_REQUEST label is present → build a reply with subject \"已读回执:<original>\" (zh) or \"Read receipt: <original>\" (en) picked by CJK detection on the original subject, In-Reply-To / References threading, and X-Lark-Read-Receipt-Mail: 1 → create draft and send. The backend extracts the private header, sets BodyExtra.IsReadReceiptMail, and DraftSend applies the READ_RECEIPT_SENT label to the outgoing message.").
|
||
GET(mailboxPath(mailboxID, "messages", messageID)).
|
||
Params(map[string]interface{}{"format": messageGetFormat(false)}).
|
||
GET(mailboxPath(mailboxID, "profile")).
|
||
POST(mailboxPath(mailboxID, "drafts")).
|
||
Body(map[string]interface{}{"raw": "<base64url-EML>"}).
|
||
POST(mailboxPath(mailboxID, "drafts", "<draft_id>", "send"))
|
||
},
|
||
// No Validate: +send-receipt takes no user-provided content (subject /
|
||
// body / recipients are all derived from the original message). The
|
||
// :send scope is declared in static Scopes above and pre-checked by
|
||
// runner.checkShortcutScopes before Execute runs, so dynamic scope
|
||
// validation here would be redundant. Mirrors +send, which also keeps
|
||
// :send in static Scopes and skips validateConfirmSendScope.
|
||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
messageID := runtime.Str("message-id")
|
||
|
||
mailboxID := resolveComposeMailboxID(runtime)
|
||
|
||
msg, err := fetchFullMessage(runtime, mailboxID, messageID, false)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to fetch original message: %w", err)
|
||
}
|
||
if !hasReadReceiptRequestLabel(msg) {
|
||
return fmt.Errorf("message %s did not request a read receipt (no %s label); refusing to send receipt", messageID, readReceiptRequestLabel)
|
||
}
|
||
|
||
origSubject := strVal(msg["subject"])
|
||
origSMTPID := normalizeMessageID(strVal(msg["smtp_message_id"]))
|
||
origFromEmail, _ := extractAddressPair(msg["head_from"])
|
||
origReferences := joinReferences(msg["references"])
|
||
origSendMillis := parseInternalDateMillis(msg["internal_date"])
|
||
|
||
if origFromEmail == "" {
|
||
return fmt.Errorf("original message %s has no sender address; cannot address receipt", messageID)
|
||
}
|
||
|
||
senderEmail := resolveComposeSenderEmail(runtime)
|
||
if senderEmail == "" {
|
||
return fmt.Errorf("unable to determine sender email; please specify --from explicitly")
|
||
}
|
||
|
||
lang := detectSubjectLang(origSubject)
|
||
readTime := time.Now()
|
||
textBody := buildReceiptTextBody(lang, origSubject, senderEmail, origSendMillis, readTime)
|
||
htmlBody := buildReceiptHTMLBody(lang, origSubject, senderEmail, origSendMillis, readTime)
|
||
|
||
bld := emlbuilder.New().WithFileIO(runtime.FileIO()).
|
||
Subject(buildReceiptSubject(origSubject)).
|
||
From("", senderEmail).
|
||
To("", origFromEmail).
|
||
TextBody([]byte(textBody)).
|
||
HTMLBody([]byte(htmlBody)).
|
||
IsReadReceiptMail(true)
|
||
if origSMTPID != "" {
|
||
bld = bld.InReplyTo(origSMTPID)
|
||
}
|
||
if refs := buildReceiptReferences(origReferences, origSMTPID); refs != "" {
|
||
bld = bld.References(refs)
|
||
}
|
||
if messageID != "" {
|
||
bld = bld.LMSReplyToMessageID(messageID)
|
||
}
|
||
|
||
rawEML, err := bld.BuildBase64URL()
|
||
if err != nil {
|
||
return fmt.Errorf("failed to build receipt EML: %w", err)
|
||
}
|
||
|
||
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create receipt draft: %w", err)
|
||
}
|
||
resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, "")
|
||
if err != nil {
|
||
return fmt.Errorf("failed to send receipt (draft %s created but not sent): %w", draftResult.DraftID, err)
|
||
}
|
||
|
||
out := buildDraftSendOutput(resData, mailboxID)
|
||
out["receipt_for_message_id"] = messageID
|
||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||
fmt.Fprintln(w, "已对原邮件发送回执 / Read receipt sent.")
|
||
fmt.Fprintf(w, "receipt_for_message_id: %s\n", messageID)
|
||
})
|
||
return nil
|
||
},
|
||
}
|
||
|
||
// hasReadReceiptRequestLabel returns true when the message's label_ids include
|
||
// either the symbolic name "READ_RECEIPT_REQUEST" or the numeric system-label
|
||
// id "-607" (backends have returned both forms historically).
|
||
func hasReadReceiptRequestLabel(msg map[string]interface{}) bool {
|
||
labels := toStringList(msg["label_ids"])
|
||
for _, l := range labels {
|
||
if l == readReceiptRequestLabel || l == "-607" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// maybeHintReadReceiptRequest prints a stderr tip if the just-read message
|
||
// carries a read-receipt request. Noop for messages without the label or
|
||
// without a resolvable message_id. Called by +message / +messages / +thread
|
||
// after primary JSON output so callers and humans both see it.
|
||
func maybeHintReadReceiptRequest(runtime *common.RuntimeContext, mailboxID, messageID string, msg map[string]interface{}) {
|
||
if messageID == "" || !hasReadReceiptRequestLabel(msg) {
|
||
return
|
||
}
|
||
fromEmail, _ := extractAddressPair(msg["head_from"])
|
||
subject := strVal(msg["subject"])
|
||
hintReadReceiptRequest(runtime, mailboxID, messageID, fromEmail, subject)
|
||
}
|
||
|
||
// buildReceiptSubject prepends the language-appropriate receipt prefix once.
|
||
// Language is detected from the original subject itself, matching
|
||
// buildReplySubject / buildForwardSubject in mail_quote.go.
|
||
//
|
||
// Idempotent: if the subject already starts with a known receipt prefix
|
||
// (zh "已读回执:" or en "Read receipt: "), the existing prefix is stripped
|
||
// before the language-appropriate one is re-applied. This matters when the
|
||
// input is already a receipt (unusual, but not rejected elsewhere) and keeps
|
||
// us from producing "Read receipt: 已读回执:..." chains.
|
||
//
|
||
// NOTE: the backend GetRealSubject regex is driven by TCC
|
||
// MailPrefixConfig.SubjectPrefixListForAdvancedSearch — that list must include
|
||
// both "已读回执:" and "Read receipt: " for conversation aggregation to work
|
||
// across languages. zh was already covered; en requires a TCC update.
|
||
func buildReceiptSubject(original string) string {
|
||
trimmed := strings.TrimSpace(original)
|
||
// Detect language on the ORIGINAL subject so that the prefix we re-apply
|
||
// matches the author's intent even when every remaining CJK character
|
||
// lives inside a prefix we're about to strip (e.g. "已读回执:已读回执:x"
|
||
// → strip both prefixes → "x", but the author obviously wanted zh).
|
||
lang := detectSubjectLang(trimmed)
|
||
// Strip either known prefix case-insensitively (en), exact (zh). Loop so
|
||
// accidental chains ("Read receipt: Read receipt: ...") collapse too.
|
||
for {
|
||
switch {
|
||
case strings.HasPrefix(trimmed, "已读回执:"):
|
||
trimmed = strings.TrimSpace(strings.TrimPrefix(trimmed, "已读回执:"))
|
||
case strings.HasPrefix(strings.ToLower(trimmed), "read receipt:"):
|
||
trimmed = strings.TrimSpace(trimmed[len("read receipt:"):])
|
||
default:
|
||
return receiptMetaLabels(lang).SubjectPrefix + trimmed
|
||
}
|
||
}
|
||
}
|
||
|
||
// buildReceiptReferences appends the original message's SMTP Message-ID to its
|
||
// existing References chain, producing the References header for the receipt.
|
||
// Both inputs are optional; the return value is a space-joined list with angle
|
||
// brackets, suitable for the emlbuilder References() method.
|
||
func buildReceiptReferences(origRefs, origSMTPID string) string {
|
||
var parts []string
|
||
if trimmed := strings.TrimSpace(origRefs); trimmed != "" {
|
||
parts = append(parts, trimmed)
|
||
}
|
||
if origSMTPID != "" {
|
||
parts = append(parts, "<"+origSMTPID+">")
|
||
}
|
||
return strings.Join(parts, " ")
|
||
}
|
||
|
||
// extractAddressPair returns (email, name) from the head_from / reply_to /
|
||
// entry in the raw /messages response, handling both object and string forms.
|
||
func extractAddressPair(v interface{}) (email, name string) {
|
||
switch t := v.(type) {
|
||
case map[string]interface{}:
|
||
email = strVal(t["mail_address"])
|
||
name = strVal(t["name"])
|
||
case string:
|
||
email = t
|
||
}
|
||
return email, name
|
||
}
|
||
|
||
// parseInternalDateMillis parses the internal_date field from a /messages
|
||
// response (which the API returns as a string-encoded Unix millisecond
|
||
// timestamp). Returns 0 if the value is missing or unparseable; callers render
|
||
// a placeholder in that case rather than erroring.
|
||
func parseInternalDateMillis(v interface{}) int64 {
|
||
s := strings.TrimSpace(strVal(v))
|
||
if s == "" {
|
||
return 0
|
||
}
|
||
ms, err := strconv.ParseInt(s, 10, 64)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return ms
|
||
}
|
||
|
||
// renderReceiptTime formats a millisecond timestamp for display inside the
|
||
// receipt body. Returns an empty-safe placeholder when the timestamp is 0.
|
||
// Reuses formatMailDate (mail_quote.go) so receipts read the same way as
|
||
// the quote block used by +reply / +forward.
|
||
func renderReceiptTime(ms int64, lang string) string {
|
||
if ms <= 0 {
|
||
return "-"
|
||
}
|
||
return formatMailDate(ms, lang)
|
||
}
|
||
|
||
// buildReceiptTextBody returns the plain-text body used when a +send-receipt
|
||
// sends the auto-generated acknowledgement. The layout mirrors the Lark PC /
|
||
// Mobile clients' receipt body: one header line followed by quoted key-value
|
||
// lines for subject / recipient / send time / read time. Callers cannot
|
||
// customize this body — the Subject field carries the receipt prefix which
|
||
// is the semantically meaningful signal; free-form user notes belong in a
|
||
// normal +reply instead.
|
||
func buildReceiptTextBody(lang, origSubject, origRecipient string, origSendMillis int64, readTime time.Time) string {
|
||
labels := receiptMetaLabels(lang)
|
||
var b strings.Builder
|
||
b.WriteString(labels.Lead)
|
||
b.WriteByte('\n')
|
||
fmt.Fprintf(&b, "> %s%s\n", labels.Subject, strings.TrimSpace(origSubject))
|
||
fmt.Fprintf(&b, "> %s%s\n", labels.To, origRecipient)
|
||
fmt.Fprintf(&b, "> %s%s\n", labels.Sent, renderReceiptTime(origSendMillis, lang))
|
||
fmt.Fprintf(&b, "> %s%s\n", labels.Read, formatMailDate(readTime.UnixMilli(), lang))
|
||
return b.String()
|
||
}
|
||
|
||
// buildReceiptHTMLBody returns the HTML body for the auto-generated receipt.
|
||
// Intentionally simpler than the Lark PC client's HTML (no branded styling,
|
||
// no proprietary markers) — just enough structure (leading statement + quoted
|
||
// key-value block) to render nicely in any MUA. All user-controlled values go
|
||
// through htmlEscape to prevent injection from the original subject / headers.
|
||
func buildReceiptHTMLBody(lang, origSubject, origRecipient string, origSendMillis int64, readTime time.Time) string {
|
||
labels := receiptMetaLabels(lang)
|
||
var b strings.Builder
|
||
b.WriteString(`<div style="word-break:break-word;">`)
|
||
b.WriteString(`<div style="margin:4px 0;line-height:1.6;font-size:14px;">`)
|
||
b.WriteString(htmlEscape(labels.Lead))
|
||
b.WriteString(`</div>`)
|
||
b.WriteString(`<div style="padding:12px;background:#f5f6f7;color:#1f2329;border-radius:4px;margin-top:12px;word-break:break-word;">`)
|
||
fmt.Fprintf(&b, `<div><span>%s</span> %s</div>`, htmlEscape(labels.Subject), htmlEscape(strings.TrimSpace(origSubject)))
|
||
fmt.Fprintf(&b, `<div><span>%s</span> %s</div>`, htmlEscape(labels.To), htmlEscape(origRecipient))
|
||
fmt.Fprintf(&b, `<div><span>%s</span> %s</div>`, htmlEscape(labels.Sent), htmlEscape(renderReceiptTime(origSendMillis, lang)))
|
||
fmt.Fprintf(&b, `<div><span>%s</span> %s</div>`, htmlEscape(labels.Read), htmlEscape(formatMailDate(readTime.UnixMilli(), lang)))
|
||
b.WriteString(`</div>`)
|
||
b.WriteString(`</div>`)
|
||
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, " ")
|
||
}
|