mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +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.
147 lines
4.9 KiB
Go
147 lines
4.9 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package mail
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
// TestMailDeclineReceipt_ShortcutMetadata verifies the shortcut is registered
|
|
// with the expected command name, risk level, and scopes. These are public
|
|
// contracts (they show up in `lark-cli mail --help` and the auth prompt);
|
|
// changes should be intentional.
|
|
func TestMailDeclineReceipt_ShortcutMetadata(t *testing.T) {
|
|
if MailDeclineReceipt.Service != "mail" {
|
|
t.Errorf("Service = %q, want %q", MailDeclineReceipt.Service, "mail")
|
|
}
|
|
if MailDeclineReceipt.Command != "+decline-receipt" {
|
|
t.Errorf("Command = %q, want %q", MailDeclineReceipt.Command, "+decline-receipt")
|
|
}
|
|
// +decline-receipt only removes a local label, no outgoing mail — Risk is
|
|
// "write", not "high-risk-write" that +send-receipt uses. Writers should
|
|
// not need --yes.
|
|
if MailDeclineReceipt.Risk != "write" {
|
|
t.Errorf("Risk = %q, want %q", MailDeclineReceipt.Risk, "write")
|
|
}
|
|
// modify scope is required to flip label_ids; readonly scopes are
|
|
// required because fetchFullMessage(..., false) hits plain_text_full
|
|
// which the backend scope-checks against body:read.
|
|
required := map[string]bool{
|
|
"mail:user_mailbox.message:modify": true,
|
|
"mail:user_mailbox.message:readonly": true,
|
|
"mail:user_mailbox:readonly": true,
|
|
"mail:user_mailbox.message.body:read": true,
|
|
}
|
|
for _, s := range MailDeclineReceipt.Scopes {
|
|
delete(required, s)
|
|
}
|
|
if len(required) != 0 {
|
|
t.Errorf("MailDeclineReceipt.Scopes missing %v", required)
|
|
}
|
|
if len(MailDeclineReceipt.AuthTypes) != 1 || MailDeclineReceipt.AuthTypes[0] != "user" {
|
|
t.Errorf("AuthTypes = %v, want [user]", MailDeclineReceipt.AuthTypes)
|
|
}
|
|
// --message-id must be marked Required so the framework fails fast
|
|
// before we enter Execute; otherwise the fetchFullMessage call would
|
|
// hit the API with an empty id.
|
|
var found bool
|
|
for _, f := range MailDeclineReceipt.Flags {
|
|
if f.Name == "message-id" && f.Required {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("--message-id flag must be marked Required")
|
|
}
|
|
}
|
|
|
|
// runtimeForMailDeclineReceiptDryRun builds a minimal runtime with the flags
|
|
// MailDeclineReceipt declares, mirroring the pattern used by
|
|
// runtimeForMailTriageTest in mail_triage_test.go.
|
|
func runtimeForMailDeclineReceiptDryRun(t *testing.T, values map[string]string) *common.RuntimeContext {
|
|
t.Helper()
|
|
cmd := &cobra.Command{Use: "test"}
|
|
for _, fl := range MailDeclineReceipt.Flags {
|
|
cmd.Flags().String(fl.Name, "", "")
|
|
}
|
|
if err := cmd.ParseFlags(nil); err != nil {
|
|
t.Fatalf("parse flags failed: %v", err)
|
|
}
|
|
for k, v := range values {
|
|
if err := cmd.Flags().Set(k, v); err != nil {
|
|
t.Fatalf("set flag --%s failed: %v", k, err)
|
|
}
|
|
}
|
|
return &common.RuntimeContext{Cmd: cmd}
|
|
}
|
|
|
|
// TestMailDeclineReceipt_DryRun verifies the DryRun plan prints the two calls
|
|
// the Execute path performs: a GET to fetch the original message (so we can
|
|
// check the READ_RECEIPT_REQUEST label is present) and a PUT to
|
|
// user_mailbox.message.modify that removes the label by its symbolic name.
|
|
// Pinning both URLs, methods, and the body shape here means a regression
|
|
// that reverts to the batch endpoint or leaks the numeric "-607" id shows
|
|
// up immediately without requiring a full integration round trip.
|
|
func TestMailDeclineReceipt_DryRun(t *testing.T) {
|
|
runtime := runtimeForMailDeclineReceiptDryRun(t, map[string]string{
|
|
"message-id": "msg-1",
|
|
})
|
|
|
|
dry := MailDeclineReceipt.DryRun(context.Background(), runtime)
|
|
raw, err := json.Marshal(dry)
|
|
if err != nil {
|
|
t.Fatalf("marshal dry-run failed: %v", err)
|
|
}
|
|
s := string(raw)
|
|
|
|
for _, want := range []string{
|
|
`"method":"GET"`,
|
|
`/user_mailboxes/me/messages/msg-1`,
|
|
`"method":"PUT"`,
|
|
`/user_mailboxes/me/messages/msg-1/modify`,
|
|
`"remove_label_ids":["READ_RECEIPT_REQUEST"]`,
|
|
} {
|
|
if !strings.Contains(s, want) {
|
|
t.Errorf("dry-run JSON missing %q; got:\n%s", want, s)
|
|
}
|
|
}
|
|
// Regression guards: batch endpoint shape and internal numeric id must
|
|
// not leak into the OpenAPI payload.
|
|
for _, forbidden := range []string{
|
|
"batch_modify_message",
|
|
`"-607"`,
|
|
`"message_ids"`,
|
|
} {
|
|
if strings.Contains(s, forbidden) {
|
|
t.Errorf("dry-run JSON should not contain %q; got:\n%s", forbidden, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestMailDeclineReceipt_DescriptionCoversFeatureIntent makes the Shortcut
|
|
// Description a tested string — it is the human-readable explanation piped
|
|
// into SKILL.md's Shortcut index table by the generator, so changes there
|
|
// should be intentional.
|
|
func TestMailDeclineReceipt_DescriptionCoversFeatureIntent(t *testing.T) {
|
|
desc := strings.ToLower(MailDeclineReceipt.Description)
|
|
for _, want := range []string{
|
|
"dismiss",
|
|
"without sending",
|
|
"read_receipt_request",
|
|
"idempotent",
|
|
} {
|
|
if !strings.Contains(desc, want) {
|
|
t.Errorf("Description should mention %q; got: %s", want, MailDeclineReceipt.Description)
|
|
}
|
|
}
|
|
}
|