Files
larksuite-cli/shortcuts/mail/mail_thread.go
xzcong0820 d92f0a2204 feat(mail): add read receipt support (--request-receipt, +send-receipt, +decline-receipt)
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.
2026-04-24 14:26:17 +08:00

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