mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 18:34:03 +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.
137 lines
5.1 KiB
Go
137 lines
5.1 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package mail
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
// mailThreadOutput is the +thread JSON output: the thread identifier,
|
|
// the number of messages in it, and the messages themselves in
|
|
// chronological order.
|
|
type mailThreadOutput struct {
|
|
ThreadID string `json:"thread_id"`
|
|
MessageCount int `json:"message_count"`
|
|
Messages []map[string]interface{} `json:"messages"`
|
|
}
|
|
|
|
// sortThreadMessagesByInternalDate filters out messages without a message_id
|
|
// and orders the rest ascending by internal_date (parsed via
|
|
// parseInternalDateMillis). Used to give +thread output a stable
|
|
// chronological order regardless of API return order.
|
|
func sortThreadMessagesByInternalDate(outs []map[string]interface{}) []map[string]interface{} {
|
|
messages := make([]map[string]interface{}, 0, len(outs))
|
|
for _, o := range outs {
|
|
if strVal(o["message_id"]) != "" {
|
|
messages = append(messages, o)
|
|
}
|
|
}
|
|
|
|
sort.Slice(messages, func(i, j int) bool {
|
|
di, _ := strconv.ParseInt(strVal(messages[i]["internal_date"]), 10, 64)
|
|
dj, _ := strconv.ParseInt(strVal(messages[j]["internal_date"]), 10, 64)
|
|
return di < dj
|
|
})
|
|
return messages
|
|
}
|
|
|
|
// MailThread is the `+thread` shortcut: fetch a full mail conversation by
|
|
// thread ID, returning every message in chronological order.
|
|
var MailThread = common.Shortcut{
|
|
Service: "mail",
|
|
Command: "+thread",
|
|
Description: "Use when querying a full mail conversation/thread by thread ID. Returns all messages in chronological order, including replies and drafts, with body content and attachments metadata, including inline images.",
|
|
Risk: "read",
|
|
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
|
AuthTypes: []string{"user", "bot"},
|
|
HasFormat: true,
|
|
Flags: []common.Flag{
|
|
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
|
|
{Name: "thread-id", Desc: "Required. Email thread ID", Required: true},
|
|
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
|
|
{Name: "include-spam-trash", Type: "bool", Desc: "Also return messages from SPAM and TRASH folders (excluded by default)"},
|
|
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
|
|
},
|
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
mailboxID := resolveMailboxID(runtime)
|
|
threadID := runtime.Str("thread-id")
|
|
params := map[string]interface{}{"format": messageGetFormat(runtime.Bool("html"))}
|
|
if runtime.Bool("include-spam-trash") {
|
|
params["include_spam_trash"] = true
|
|
}
|
|
return common.NewDryRunAPI().
|
|
Desc("Fetch all emails in thread with full body content").
|
|
GET(mailboxPath(mailboxID, "threads", threadID)).
|
|
Params(params)
|
|
},
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
if runtime.Bool("print-output-schema") {
|
|
printMessageOutputSchema(runtime)
|
|
return nil
|
|
}
|
|
mailboxID := resolveMailboxID(runtime)
|
|
hintIdentityFirst(runtime, mailboxID)
|
|
threadID := runtime.Str("thread-id")
|
|
html := runtime.Bool("html")
|
|
|
|
// List thread messages with full body content in one call.
|
|
params := map[string]interface{}{"format": messageGetFormat(html)}
|
|
if runtime.Bool("include-spam-trash") {
|
|
params["include_spam_trash"] = true
|
|
}
|
|
listData, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "threads", threadID), params, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get thread: %w", err)
|
|
}
|
|
// New API: data.thread.messages[]; fallback to old API: data.items[].message
|
|
var items []interface{}
|
|
if thread, ok := listData["thread"].(map[string]interface{}); ok {
|
|
items, _ = thread["messages"].([]interface{})
|
|
}
|
|
if len(items) == 0 {
|
|
items, _ = listData["items"].([]interface{})
|
|
}
|
|
if len(items) == 0 {
|
|
runtime.Out(mailThreadOutput{ThreadID: threadID, MessageCount: 0, Messages: []map[string]interface{}{}}, nil)
|
|
return nil
|
|
}
|
|
|
|
outs := make([]map[string]interface{}, 0, len(items))
|
|
for _, item := range items {
|
|
envelope, ok := item.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
// Old API wraps each message inside a "message" sub-object; new API puts fields directly.
|
|
msg := envelope
|
|
if inner, ok := envelope["message"].(map[string]interface{}); ok {
|
|
msg = inner
|
|
}
|
|
outs = append(outs, buildMessageOutput(msg, html))
|
|
}
|
|
|
|
// Sort by internal_date ascending.
|
|
messages := sortThreadMessagesByInternalDate(outs)
|
|
|
|
runtime.Out(mailThreadOutput{ThreadID: threadID, MessageCount: len(messages), Messages: messages}, nil)
|
|
for _, item := range items {
|
|
envelope, ok := item.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
msg := envelope
|
|
if inner, ok := envelope["message"].(map[string]interface{}); ok {
|
|
msg = inner
|
|
}
|
|
maybeHintReadReceiptRequest(runtime, mailboxID, strVal(msg["message_id"]), msg)
|
|
}
|
|
return nil
|
|
},
|
|
}
|