Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions shortcuts/mail/draft/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,13 @@ func (op PatchOp) Validate() error {
if strings.TrimSpace(op.Name) == "" {
return fmt.Errorf("remove_header requires name")
}
case "set_send_separately":
switch strings.ToLower(strings.TrimSpace(op.Value)) {
case "true", "false", "1", "0":
// ok
default:
return fmt.Errorf(`set_send_separately: value must be "true", "false", "1", or "0"`)
}
case "add_attachment":
if strings.TrimSpace(op.Path) == "" {
return fmt.Errorf("add_attachment requires path")
Expand Down
13 changes: 13 additions & 0 deletions shortcuts/mail/draft/patch.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,19 @@ func applyOp(dctx *DraftCtx, snapshot *DraftSnapshot, op PatchOp, options PatchO
return err
}
removeHeader(&snapshot.Headers, op.Name)
case "set_send_separately":
// Translate the typed op into a header upsert/remove on the
// X-Lms-Send-Separately header — the same surface as +draft-create
// / +send --send-separately. "true" / "1" enables; "false" / "0"
// (or anything else with op.Value rejected by Validate) removes.
switch strings.ToLower(strings.TrimSpace(op.Value)) {
case "true", "1":
upsertHeader(&snapshot.Headers, "X-Lms-Send-Separately", "1")
case "false", "0":
removeHeader(&snapshot.Headers, "X-Lms-Send-Separately")
default:
return fmt.Errorf(`set_send_separately: value must be "true", "false", "1", or "0"`)
}
case "add_attachment":
return addAttachment(dctx, snapshot, op.Path)
case "remove_attachment":
Expand Down
177 changes: 177 additions & 0 deletions shortcuts/mail/draft/patch_send_separately_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package draft

import (
"strings"
"testing"
)

// fixtureSendSeparatelyDraft is a minimal draft used by every send-
// separately patch test below. Keeping it inline (vs a shared helper)
// keeps each test self-contained.
const fixtureSendSeparatelyDraft = `Subject: Test
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8

hello
`

// TestApplySetSendSeparatelyTrueAddsHeader verifies the "true" value
// inserts X-Lms-Send-Separately: 1 once.
func TestApplySetSendSeparatelyTrueAddsHeader(t *testing.T) {
snapshot := mustParseFixtureDraft(t, fixtureSendSeparatelyDraft)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_send_separately", Value: "true"}},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
got := headerValue(snapshot.Headers, "X-Lms-Send-Separately")
if got != "1" {
t.Fatalf("X-Lms-Send-Separately = %q, want %q", got, "1")
}
}

// TestApplySetSendSeparatelyOneAddsHeader covers the "1" alias for true.
func TestApplySetSendSeparatelyOneAddsHeader(t *testing.T) {
snapshot := mustParseFixtureDraft(t, fixtureSendSeparatelyDraft)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_send_separately", Value: "1"}},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
if got := headerValue(snapshot.Headers, "X-Lms-Send-Separately"); got != "1" {
t.Fatalf("X-Lms-Send-Separately = %q, want %q", got, "1")
}
}

// TestApplySetSendSeparatelyFalseRemovesHeader verifies "false" deletes
// any existing X-Lms-Send-Separately header.
func TestApplySetSendSeparatelyFalseRemovesHeader(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Test
From: Alice <alice@example.com>
To: Bob <bob@example.com>
X-Lms-Send-Separately: 1
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8

hello
`)
if got := headerValue(snapshot.Headers, "X-Lms-Send-Separately"); got != "1" {
t.Fatalf("precondition: header missing; got %q", got)
}
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_send_separately", Value: "false"}},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
if got := headerValue(snapshot.Headers, "X-Lms-Send-Separately"); got != "" {
t.Fatalf("X-Lms-Send-Separately still present: %q", got)
}
}

// TestApplySetSendSeparatelyZeroRemovesHeader covers the "0" alias for
// false.
func TestApplySetSendSeparatelyZeroRemovesHeader(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Test
From: Alice <alice@example.com>
To: Bob <bob@example.com>
X-Lms-Send-Separately: 1
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8

hello
`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_send_separately", Value: "0"}},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
if got := headerValue(snapshot.Headers, "X-Lms-Send-Separately"); got != "" {
t.Fatalf("X-Lms-Send-Separately still present: %q", got)
}
}

// TestApplySetSendSeparatelyCaseInsensitive verifies the value parsing
// tolerates mixed case (mirroring the data-access caseEqualFold check).
func TestApplySetSendSeparatelyCaseInsensitive(t *testing.T) {
for _, v := range []string{"TRUE", "True", "tRuE"} {
t.Run(v, func(t *testing.T) {
snapshot := mustParseFixtureDraft(t, fixtureSendSeparatelyDraft)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_send_separately", Value: v}},
})
if err != nil {
t.Fatalf("Apply(%q) error = %v", v, err)
}
if got := headerValue(snapshot.Headers, "X-Lms-Send-Separately"); got != "1" {
t.Fatalf("X-Lms-Send-Separately = %q, want %q", got, "1")
}
})
}
}

// TestApplySetSendSeparatelyInvalidValueRejected verifies values outside
// {true,false,1,0} are rejected at Validate time (before applyOp runs),
// so the snapshot is left untouched.
func TestApplySetSendSeparatelyInvalidValueRejected(t *testing.T) {
for _, v := range []string{"yes", "no", "on", "", "2", "maybe"} {
t.Run("value="+v, func(t *testing.T) {
snapshot := mustParseFixtureDraft(t, fixtureSendSeparatelyDraft)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "set_send_separately", Value: v}},
})
if err == nil {
t.Fatalf("expected error for invalid value %q, got nil", v)
}
if !strings.Contains(err.Error(), "set_send_separately") {
t.Fatalf("error %v does not mention op name; got %v", err, err)
}
if got := headerValue(snapshot.Headers, "X-Lms-Send-Separately"); got != "" {
t.Fatalf("snapshot mutated on validation failure; X-Lms-Send-Separately = %q", got)
}
})
}
}

// TestPatchOpValidateSendSeparately exercises the PatchOp.Validate()
// shape directly (the same gate that Patch.Validate() invokes for every
// op).
func TestPatchOpValidateSendSeparately(t *testing.T) {
for _, v := range []string{"true", "false", "1", "0", "TRUE", "False"} {
if err := (PatchOp{Op: "set_send_separately", Value: v}).Validate(); err != nil {
t.Errorf("Validate(%q) = %v, want nil", v, err)
}
}
for _, v := range []string{"yes", "no", "", "2"} {
if err := (PatchOp{Op: "set_send_separately", Value: v}).Validate(); err == nil {
t.Errorf("Validate(%q) = nil, want error", v)
}
}
}

// TestApplySetSendSeparatelyTrueThenFalse verifies the toggle semantics
// (set, then unset) end up with no header — exercising the upsert /
// remove path together in a single test.
func TestApplySetSendSeparatelyTrueThenFalse(t *testing.T) {
snapshot := mustParseFixtureDraft(t, fixtureSendSeparatelyDraft)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{
{Op: "set_send_separately", Value: "true"},
{Op: "set_send_separately", Value: "false"},
},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
if got := headerValue(snapshot.Headers, "X-Lms-Send-Separately"); got != "" {
t.Fatalf("X-Lms-Send-Separately should be cleared after true→false; got %q", got)
}
}
22 changes: 22 additions & 0 deletions shortcuts/mail/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2084,6 +2084,28 @@ func applyPriority(bld emlbuilder.Builder, priority string) emlbuilder.Builder {
return bld.Header("X-Cli-Priority", priority)
}

// sendSeparatelyEmlHeader is the EML header injected when a compose
// shortcut is invoked with --send-separately. The mail backend reads
// this header at send time and splits the envelope into per-recipient
// copies so each recipient only sees themselves in the To/Cc header.
// Bcc visibility is unaffected.
const sendSeparatelyEmlHeader = "X-Lms-Send-Separately"

// isMailErrno6002 reports whether err carries the Lark mail backend's
// "invalid draft message format" errno (6002). The OAPI gateway surfaces
// the errno in the error message; we string-match it here because
// CallAPI returns a wrapped error rather than a typed APIError. Used by
// compose shortcuts to attach a hint when --send-separately is set but
// the backend rejects the header — that combination is the classic
// "backend not yet updated" failure mode for this feature.
func isMailErrno6002(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "6002")
}
Comment on lines +2101 to +2107
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Tighten errno 6002 matching to avoid false-positive hints.

strings.Contains(msg, "6002") can match unrelated error text (e.g., 16002) and attach the wrong remediation hint.

Suggested fix
+var mailErrno6002Re = regexp.MustCompile(`(?:^|[^0-9])6002(?:[^0-9]|$)`)
+
 func isMailErrno6002(err error) bool {
 	if err == nil {
 		return false
 	}
-	msg := err.Error()
-	return strings.Contains(msg, "6002")
+	return mailErrno6002Re.MatchString(err.Error())
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func isMailErrno6002(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "6002")
}
var mailErrno6002Re = regexp.MustCompile(`(?:^|[^0-9])6002(?:[^0-9]|$)`)
func isMailErrno6002(err error) bool {
if err == nil {
return false
}
return mailErrno6002Re.MatchString(err.Error())
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/mail/helpers.go` around lines 2101 - 2107, The current
isMailErrno6002 uses strings.Contains which can false-positive on numbers like
"16002"; update isMailErrno6002 to match the standalone error code using a regex
word-boundary match (e.g., use regexp.MatchString with pattern `\b6002\b`) so
only the exact token "6002" (not digits embedded in other numbers) triggers
true; compile the regexp once (package-level var) and replace the
strings.Contains check in isMailErrno6002 with regexp.Match to avoid performance
hits and ensure correct matching.


// parseNetAddrs converts a comma-separated address string to []net/mail.Address.
// It reuses ParseMailboxList for display-name-aware parsing and deduplicates
// by email address (case-insensitive), preserving the first occurrence.
Expand Down
64 changes: 44 additions & 20 deletions shortcuts/mail/mail_draft_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,16 @@ import (
// struct so parseDraftCreateInput / buildRawEMLForDraftCreate have a
// uniform value type to pass around.
type draftCreateInput struct {
To string
Subject string
Body string
From string
CC string
BCC string
Attach string
Inline string
PlainText bool
To string
Subject string
Body string
From string
CC string
BCC string
Attach string
Inline string
PlainText bool
SendSeparately bool
}

// MailDraftCreate is the `+draft-create` shortcut: create a brand-new mail
Expand All @@ -54,6 +55,7 @@ var MailDraftCreate = common.Shortcut{
{Name: "inline", Desc: "Optional. Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed."},
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's subject/body/to/cc/bcc/attachments are merged with user-supplied flags (user flags win). Requires --as user."},
{Name: "send-separately", Type: "bool", Desc: "Mark the draft as 'send separately': at send time each recipient (To/Cc) only sees themselves in the To/Cc header; other recipients are not exposed. Stacks with --cc/--bcc/--attach/--inline/--plain-text/--template-id/--request-receipt/--signature-id/--priority. Differs from Bcc: Bcc hides recipients from everyone, while --send-separately makes every recipient appear to be the sole To/Cc addressee. Has no observable effect when there is only one recipient in total."},
signatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
Expand All @@ -71,8 +73,9 @@ var MailDraftCreate = common.Shortcut{
Body(map[string]interface{}{
"raw": "<base64url-EML>",
"_preview": map[string]interface{}{
"to": runtime.Str("to"),
"subject": runtime.Str("subject"),
"to": runtime.Str("to"),
"subject": runtime.Str("subject"),
"send_separately": runtime.Bool("send-separately"),
},
})
return api
Expand Down Expand Up @@ -106,15 +109,16 @@ var MailDraftCreate = common.Shortcut{
}
mailboxID := resolveComposeMailboxID(runtime)
input := draftCreateInput{
To: runtime.Str("to"),
Subject: runtime.Str("subject"),
Body: runtime.Str("body"),
From: runtime.Str("from"),
CC: runtime.Str("cc"),
BCC: runtime.Str("bcc"),
Attach: runtime.Str("attach"),
Inline: runtime.Str("inline"),
PlainText: runtime.Bool("plain-text"),
To: runtime.Str("to"),
Subject: runtime.Str("subject"),
Body: runtime.Str("body"),
From: runtime.Str("from"),
CC: runtime.Str("cc"),
BCC: runtime.Str("bcc"),
Attach: runtime.Str("attach"),
Inline: runtime.Str("inline"),
PlainText: runtime.Bool("plain-text"),
SendSeparately: runtime.Bool("send-separately"),
}
var templateLargeAttachmentIDs []string
var templateInlineAttachments []templateInlineRef
Expand Down Expand Up @@ -163,6 +167,18 @@ var MailDraftCreate = common.Shortcut{
if strings.TrimSpace(input.Body) == "" {
return output.ErrValidation("effective body is empty after applying template; pass --body explicitly")
}
// Post-template-merge: warn (but do not reject) when --send-separately
// is set with exactly one effective recipient; a single recipient
// derives no benefit from per-recipient envelope splitting. Zero
// recipients is intentionally allowed for +draft-create (recipients
// can still be added later via +draft-edit). The user can also add
// more recipients later, so do not error out.
if input.SendSeparately {
total := countAddresses(input.To) + countAddresses(input.CC) + countAddresses(input.BCC)
if total == 1 {
fmt.Fprintf(runtime.IO().ErrOut, "warning: --send-separately has no observable effect with only 1 recipient; add more via --cc/--bcc or +draft-edit\n")
}
Comment on lines +177 to +180
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Single-recipient warning logic should key off To/Cc recipients only.

Including Bcc in the count can hide this warning even when send-separately has no visible To/Cc impact.

Suggested fix
-			total := countAddresses(input.To) + countAddresses(input.CC) + countAddresses(input.BCC)
-			if total == 1 {
+			visibleRecipients := countAddresses(input.To) + countAddresses(input.CC)
+			if visibleRecipients <= 1 {
 				fmt.Fprintf(runtime.IO().ErrOut, "warning: --send-separately has no observable effect with only 1 recipient; add more via --cc/--bcc or +draft-edit\n")
 			}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
total := countAddresses(input.To) + countAddresses(input.CC) + countAddresses(input.BCC)
if total == 1 {
fmt.Fprintf(runtime.IO().ErrOut, "warning: --send-separately has no observable effect with only 1 recipient; add more via --cc/--bcc or +draft-edit\n")
}
visibleRecipients := countAddresses(input.To) + countAddresses(input.CC)
if visibleRecipients <= 1 {
fmt.Fprintf(runtime.IO().ErrOut, "warning: --send-separately has no observable effect with only 1 recipient; add more via --cc/--bcc or +draft-edit\n")
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/mail/mail_draft_create.go` around lines 177 - 180, The warning
logic currently counts BCC when computing total recipients, which can suppress
the single-recipient warning; change the computation to only include To and CC
addresses (use countAddresses(input.To) + countAddresses(input.CC)) so the
warning about --send-separately triggers correctly when there is only one
visible To/Cc recipient; update the block around total := ... and the subsequent
if total == 1 check in mail_draft_create.go (referencing countAddresses and
input.To/input.CC) accordingly.

}
sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from"))
if err != nil {
return err
Expand All @@ -174,6 +190,11 @@ var MailDraftCreate = common.Shortcut{
}
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
if input.SendSeparately && isMailErrno6002(err) {
return output.ErrWithHint(output.ExitAPI, "api_error",
fmt.Sprintf("create draft failed: %v", err),
"--send-separately requires the backend to support the X-Lms-Send-Separately header; verify open-access / data-access version is up to date")
}
return fmt.Errorf("create draft failed: %w", err)
}
out := map[string]interface{}{"draft_id": draftResult.DraftID}
Expand Down Expand Up @@ -304,6 +325,9 @@ func buildRawEMLForDraftCreate(
return "", err
}
bld = applyPriority(bld, priority)
if input.SendSeparately {
bld = bld.Header(sendSeparatelyEmlHeader, "1")
}
if calData := buildCalendarBody(runtime, senderEmail, input.To, input.CC); calData != nil {
bld = bld.CalendarBody(calData)
}
Expand Down
Loading
Loading