Files
larksuite-cli/shortcuts/mail/mail_decline_receipt.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

98 lines
4.1 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// MailDeclineReceipt is the `+decline-receipt` shortcut: dismiss the read-
// receipt request banner on an incoming message WITHOUT sending a receipt.
// Mirrors the Lark client's "不发送" button next to the read-receipt prompt —
// the client talks to the internal MessageModify RPC with RemoveLabelIds=
// ["-607"]; this shortcut calls the public OpenAPI user_mailbox.message.modify
// which accepts the symbolic "READ_RECEIPT_REQUEST" label name (the public
// endpoint performs the symbolic→numeric translation server-side). Either
// path lands on the same MessageModify codepath, closing the banner.
// Removes only the READ_RECEIPT_REQUEST system label; no outgoing mail is
// produced. Idempotent: running it on a message that no longer carries the
// label is a no-op, not an error.
var MailDeclineReceipt = common.Shortcut{
Service: "mail",
Command: "+decline-receipt",
Description: "Dismiss the read-receipt request banner on an incoming mail by clearing its READ_RECEIPT_REQUEST label, without sending a receipt. Use when the user wants to silence the prompt but refuse to confirm they have read it. Idempotent — safe to re-run.",
Risk: "write",
Scopes: []string{
"mail:user_mailbox.message:modify",
"mail:user_mailbox.message:readonly",
"mail:user_mailbox:readonly",
// fetchFullMessage(..., false) uses format=plain_text_full which the
// backend scope-checks against body:read even though we only inspect
// label_ids. Declared explicitly to keep Scopes truthful.
"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 message (default: me)."},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageID := runtime.Str("message-id")
mailboxID := resolveComposeMailboxID(runtime)
return common.NewDryRunAPI().
Desc("Decline read receipt: fetch the original message → verify the READ_RECEIPT_REQUEST label is present → PUT user_mailbox.message.modify (the OpenAPI wrapper around the MessageModify RPC the Lark client's \"不发送\" button triggers) with remove_label_ids=[\"READ_RECEIPT_REQUEST\"]. No outgoing mail is produced; the banner is cleared locally. Idempotent when the label is already absent.").
GET(mailboxPath(mailboxID, "messages", messageID)).
Params(map[string]interface{}{"format": messageGetFormat(false)}).
PUT(mailboxPath(mailboxID, "messages", messageID, "modify")).
Body(map[string]interface{}{
"remove_label_ids": []string{readReceiptRequestLabel},
})
},
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)
}
out := map[string]interface{}{
"message_id": messageID,
"decline_receipt_for_id": messageID,
}
if !hasReadReceiptRequestLabel(msg) {
out["declined"] = false
out["already_cleared"] = true
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintln(w, "Read-receipt request already cleared — nothing to do.")
fmt.Fprintf(w, "message_id: %s\n", messageID)
})
return nil
}
if _, err := runtime.CallAPI("PUT",
mailboxPath(mailboxID, "messages", messageID, "modify"),
nil,
map[string]interface{}{
"remove_label_ids": []string{readReceiptRequestLabel},
},
); err != nil {
return fmt.Errorf("failed to clear READ_RECEIPT_REQUEST label: %w", err)
}
out["declined"] = true
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintln(w, "已关闭已读回执请求(未发送回执)/ Read-receipt request dismissed (no receipt sent).")
fmt.Fprintf(w, "message_id: %s\n", messageID)
})
return nil
},
}