diff --git a/shortcuts/mail/draft/model.go b/shortcuts/mail/draft/model.go index e4f190fb8..8d9b0513f 100644 --- a/shortcuts/mail/draft/model.go +++ b/shortcuts/mail/draft/model.go @@ -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") diff --git a/shortcuts/mail/draft/patch.go b/shortcuts/mail/draft/patch.go index 861045058..22dc0b29b 100644 --- a/shortcuts/mail/draft/patch.go +++ b/shortcuts/mail/draft/patch.go @@ -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": diff --git a/shortcuts/mail/draft/patch_send_separately_test.go b/shortcuts/mail/draft/patch_send_separately_test.go new file mode 100644 index 000000000..6f5ca7166 --- /dev/null +++ b/shortcuts/mail/draft/patch_send_separately_test.go @@ -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 +To: Bob +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 +To: Bob +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 +To: Bob +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) + } +} diff --git a/shortcuts/mail/helpers.go b/shortcuts/mail/helpers.go index f34eac8e1..f1fb84686 100644 --- a/shortcuts/mail/helpers.go +++ b/shortcuts/mail/helpers.go @@ -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") +} + // 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. diff --git a/shortcuts/mail/mail_draft_create.go b/shortcuts/mail/mail_draft_create.go index 990630996..5fa44ac78 100644 --- a/shortcuts/mail/mail_draft_create.go +++ b/shortcuts/mail/mail_draft_create.go @@ -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 @@ -54,6 +55,7 @@ var MailDraftCreate = common.Shortcut{ {Name: "inline", Desc: "Optional. Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via 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, @@ -71,8 +73,9 @@ var MailDraftCreate = common.Shortcut{ Body(map[string]interface{}{ "raw": "", "_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 @@ -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 @@ -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") + } + } sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from")) if err != nil { return err @@ -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} @@ -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) } diff --git a/shortcuts/mail/mail_draft_create_test.go b/shortcuts/mail/mail_draft_create_test.go index e9def904d..cb6fb41f2 100644 --- a/shortcuts/mail/mail_draft_create_test.go +++ b/shortcuts/mail/mail_draft_create_test.go @@ -420,3 +420,152 @@ func TestMailDraftCreate_WithCalendarEventFlags(t *testing.T) { t.Errorf("expected event summary in ICS:\n%s", eml) } } + +// TestBuildRawEMLForDraftCreate_SendSeparatelyAddsHeader verifies that +// passing SendSeparately=true injects the X-Lms-Send-Separately: 1 +// header into the EML built by buildRawEMLForDraftCreate. Mirrors the +// shape of TestBuildRawEMLForDraftCreate_WithPriority. +func TestBuildRawEMLForDraftCreate_SendSeparatelyAddsHeader(t *testing.T) { + input := draftCreateInput{ + From: "sender@example.com", + To: "alice@example.com,bob@example.com", + Subject: "separately test", + Body: `

Hello

`, + SendSeparately: true, + } + + rawEML, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil) + if err != nil { + t.Fatalf("buildRawEMLForDraftCreate() error = %v", err) + } + eml := decodeBase64URL(rawEML) + if !strings.Contains(eml, "X-Lms-Send-Separately: 1") { + t.Errorf("expected X-Lms-Send-Separately: 1 in EML, got:\n%s", eml) + } + // Verify only emitted once (Builder.Header appends; a regression that + // double-injects would surface here). + if c := strings.Count(eml, "X-Lms-Send-Separately"); c != 1 { + t.Errorf("expected X-Lms-Send-Separately to appear exactly 1 time, got %d", c) + } +} + +// TestBuildRawEMLForDraftCreate_SendSeparatelyOmittedByDefault verifies +// the header is absent when SendSeparately is false (the zero value). +func TestBuildRawEMLForDraftCreate_SendSeparatelyOmittedByDefault(t *testing.T) { + input := draftCreateInput{ + From: "sender@example.com", + To: "alice@example.com", + Subject: "no separately", + Body: `

Hello

`, + } + + rawEML, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil) + if err != nil { + t.Fatalf("buildRawEMLForDraftCreate() error = %v", err) + } + eml := decodeBase64URL(rawEML) + if strings.Contains(eml, "X-Lms-Send-Separately") { + t.Errorf("expected no X-Lms-Send-Separately header when SendSeparately is false, got:\n%s", eml) + } +} + +// TestMailDraftCreate_SendSeparatelyDryRunPreview verifies that +// `--send-separately` is surfaced in the dry-run _preview payload, so +// callers / AI agents can confirm the flag took effect without sending. +func TestMailDraftCreate_SendSeparatelyDryRunPreview(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + + err := runMountedMailShortcut(t, MailDraftCreate, []string{ + "+draft-create", + "--to", "alice@example.com,bob@example.com", + "--subject", "preview", + "--body", "

hello

", + "--send-separately", + "--dry-run", + }, f, stdout) + if err != nil { + t.Fatalf("dry-run failed: %v", err) + } + out := stdout.String() + if !strings.Contains(out, `"send_separately"`) { + t.Fatalf("expected dry-run preview to surface send_separately, got: %s", out) + } + if !strings.Contains(out, "true") { + t.Fatalf("expected dry-run preview send_separately=true, got: %s", out) + } +} + +// TestMailDraftCreate_SendSeparatelySingleRecipientWarn verifies that a +// single effective recipient triggers a stderr warning but does NOT +// reject the request. +func TestMailDraftCreate_SendSeparatelySingleRecipientWarn(t *testing.T) { + f, stdout, stderr, reg := mailShortcutTestFactory(t) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/user_mailboxes/me/profile", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"primary_email_address": "me@example.com"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/user_mailboxes/me/drafts", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"draft_id": "draft_ss_001"}, + }, + }) + + err := runMountedMailShortcut(t, MailDraftCreate, []string{ + "+draft-create", + "--to", "alice@example.com", + "--subject", "single", + "--body", "

hi

", + "--send-separately", + }, f, stdout) + if err != nil { + t.Fatalf("expected draft to succeed with single recipient + send-separately, got error: %v", err) + } + if !strings.Contains(stderr.String(), "--send-separately has no observable effect with only 1 recipient") { + t.Errorf("expected single-recipient warning on stderr, got: %s", stderr.String()) + } +} + +// TestMailDraftCreate_SendSeparatelyMultiRecipientNoWarn verifies that +// multiple recipients do not trip the warning. +func TestMailDraftCreate_SendSeparatelyMultiRecipientNoWarn(t *testing.T) { + f, stdout, stderr, reg := mailShortcutTestFactory(t) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/user_mailboxes/me/profile", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"primary_email_address": "me@example.com"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/user_mailboxes/me/drafts", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"draft_id": "draft_ss_002"}, + }, + }) + + err := runMountedMailShortcut(t, MailDraftCreate, []string{ + "+draft-create", + "--to", "alice@example.com,bob@example.com", + "--subject", "multi", + "--body", "

hi

", + "--send-separately", + }, f, stdout) + if err != nil { + t.Fatalf("expected draft to succeed: %v", err) + } + if strings.Contains(stderr.String(), "single recipient") || strings.Contains(stderr.String(), "no observable effect") { + t.Errorf("did not expect single-recipient warning with 2 recipients, got: %s", stderr.String()) + } +} diff --git a/shortcuts/mail/mail_draft_edit.go b/shortcuts/mail/mail_draft_edit.go index 3ae2a411b..56ac1e8b1 100644 --- a/shortcuts/mail/mail_draft_edit.go +++ b/shortcuts/mail/mail_draft_edit.go @@ -476,6 +476,7 @@ func buildDraftEditPatchTemplate() map[string]interface{} { {"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; quote block, signature, and attachment cards are auto-preserved; supports — local paths auto-resolved to inline MIME parts)"}}, {"op": "set_header", "shape": map[string]interface{}{"name": "string", "value": "string"}}, {"op": "remove_header", "shape": map[string]interface{}{"name": "string"}}, + {"op": "set_send_separately", "shape": map[string]interface{}{"value": "\"true\"|\"false\"|\"1\"|\"0\""}, "note": "marks draft as send-separately; on send, each To/Cc recipient sees only themselves. Stores as EML header X-Lms-Send-Separately: 1; \"false\"/\"0\" removes the header. Differs from Bcc: Bcc hides recipients from everyone, while send-separately makes every recipient appear sole."}, {"op": "add_attachment", "shape": map[string]interface{}{"path": "string(relative path)"}}, {"op": "remove_attachment", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional, for normal attachment)", "cid": "string(optional, for normal attachment)", "token": "string(optional, for large attachment; from large_attachments_summary in --inspect)"}}}, {"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}, "note": "advanced: prefer in set_body/set_reply_body instead"}, @@ -506,6 +507,7 @@ func buildDraftEditPatchTemplate() map[string]interface{} { "ops": []map[string]interface{}{ {"op": "set_header", "shape": map[string]interface{}{"name": "string", "value": "string"}}, {"op": "remove_header", "shape": map[string]interface{}{"name": "string"}}, + {"op": "set_send_separately", "shape": map[string]interface{}{"value": "\"true\"|\"false\"|\"1\"|\"0\""}, "note": "marks draft as send-separately via X-Lms-Send-Separately header; \"false\"/\"0\" removes it"}, }, }, { diff --git a/shortcuts/mail/mail_send.go b/shortcuts/mail/mail_send.go index 9ea3b422a..14660519b 100644 --- a/shortcuts/mail/mail_send.go +++ b/shortcuts/mail/mail_send.go @@ -38,6 +38,7 @@ var MailSend = common.Shortcut{ {Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."}, {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/--confirm-send. 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}, @@ -60,8 +61,9 @@ var MailSend = common.Shortcut{ Body(map[string]interface{}{ "raw": "", "_preview": map[string]interface{}{ - "to": to, - "subject": subject, + "to": to, + "subject": subject, + "send_separately": runtime.Bool("send-separately"), }, }) if confirmSend { @@ -116,6 +118,7 @@ var MailSend = common.Shortcut{ inlineFlag := runtime.Str("inline") confirmSend := runtime.Bool("confirm-send") sendTime := runtime.Str("send-time") + sendSeparately := runtime.Bool("send-separately") senderEmail := resolveComposeSenderEmail(runtime) signatureID := runtime.Str("signature-id") @@ -176,6 +179,18 @@ var MailSend = common.Shortcut{ } } + // Post-merge single-recipient warn for --send-separately: at this + // point To/Cc/Bcc reflect both user input and any template-supplied + // addresses. Warn only — do not reject — so a user who plans to + // add more recipients via +draft-edit before sending can still + // stage the draft. + if sendSeparately { + total := countAddresses(to) + countAddresses(ccFlag) + countAddresses(bccFlag) + 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") + } + } + sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail) if err != nil { return err @@ -256,6 +271,9 @@ var MailSend = common.Shortcut{ return err } bld = applyPriority(bld, priority) + if sendSeparately { + bld = bld.Header(sendSeparatelyEmlHeader, "1") + } if calData := buildCalendarBody(runtime, senderEmail, to, ccFlag); calData != nil { bld = bld.CalendarBody(calData) } @@ -281,6 +299,11 @@ var MailSend = common.Shortcut{ draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML) if err != nil { + if sendSeparately && isMailErrno6002(err) { + return output.ErrWithHint(output.ExitAPI, "api_error", + fmt.Sprintf("failed to create draft: %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("failed to create draft: %w", err) } if !confirmSend { diff --git a/shortcuts/mail/mail_send_test.go b/shortcuts/mail/mail_send_test.go new file mode 100644 index 000000000..8acd97ce7 --- /dev/null +++ b/shortcuts/mail/mail_send_test.go @@ -0,0 +1,179 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +// TestMailSend_SendSeparatelyHeaderInjected verifies that running `+send` +// with --send-separately injects X-Lms-Send-Separately: 1 into the +// base64url-encoded raw EML POSTed to drafts.create. Mirrors the +// inspection style used by TestMailDraftCreate_WithCalendarEventFlags +// (decode CapturedBody → raw → base64url decode → assert substring). +func TestMailSend_SendSeparatelyHeaderInjected(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/user_mailboxes/me/profile", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"primary_email_address": "me@example.com"}, + }, + }) + draftsStub := &httpmock.Stub{ + Method: "POST", + URL: "/user_mailboxes/me/drafts", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"draft_id": "draft_send_ss_001"}, + }, + } + reg.Register(draftsStub) + + err := runMountedMailShortcut(t, MailSend, []string{ + "+send", + "--to", "alice@example.com,bob@example.com", + "--subject", "send-separately", + "--body", "

hi

", + "--send-separately", + }, f, stdout) + if err != nil { + t.Fatalf("+send with --send-separately failed: %v", err) + } + + var reqBody map[string]interface{} + if err := json.Unmarshal(draftsStub.CapturedBody, &reqBody); err != nil { + t.Fatalf("unmarshal captured request body: %v", err) + } + raw, _ := reqBody["raw"].(string) + decoded, decErr := base64.URLEncoding.DecodeString(raw) + if decErr != nil { + t.Fatalf("base64url decode raw: %v", decErr) + } + eml := string(decoded) + if !strings.Contains(eml, "X-Lms-Send-Separately: 1") { + t.Errorf("expected X-Lms-Send-Separately: 1 in EML, got:\n%s", eml) + } + if c := strings.Count(eml, "X-Lms-Send-Separately"); c != 1 { + t.Errorf("expected X-Lms-Send-Separately to appear exactly 1 time, got %d", c) + } +} + +// TestMailSend_SendSeparatelyAbsentByDefault verifies that omitting the +// flag results in no X-Lms-Send-Separately header. +func TestMailSend_SendSeparatelyAbsentByDefault(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/user_mailboxes/me/profile", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"primary_email_address": "me@example.com"}, + }, + }) + draftsStub := &httpmock.Stub{ + Method: "POST", + URL: "/user_mailboxes/me/drafts", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"draft_id": "draft_send_ss_002"}, + }, + } + reg.Register(draftsStub) + + err := runMountedMailShortcut(t, MailSend, []string{ + "+send", + "--to", "alice@example.com", + "--subject", "no separately", + "--body", "

hi

", + }, f, stdout) + if err != nil { + t.Fatalf("+send without --send-separately failed: %v", err) + } + + var reqBody map[string]interface{} + if err := json.Unmarshal(draftsStub.CapturedBody, &reqBody); err != nil { + t.Fatalf("unmarshal captured request body: %v", err) + } + raw, _ := reqBody["raw"].(string) + decoded, decErr := base64.URLEncoding.DecodeString(raw) + if decErr != nil { + t.Fatalf("base64url decode raw: %v", decErr) + } + eml := string(decoded) + if strings.Contains(eml, "X-Lms-Send-Separately") { + t.Errorf("expected no X-Lms-Send-Separately header by default, got:\n%s", eml) + } +} + +// TestMailSend_SendSeparatelyDryRunPreview verifies that the dry-run +// preview map surfaces the send_separately key, mirroring +draft-create. +func TestMailSend_SendSeparatelyDryRunPreview(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + + err := runMountedMailShortcut(t, MailSend, []string{ + "+send", + "--to", "alice@example.com,bob@example.com", + "--subject", "preview", + "--body", "

hello

", + "--send-separately", + "--dry-run", + }, f, stdout) + if err != nil { + t.Fatalf("dry-run failed: %v", err) + } + out := stdout.String() + if !strings.Contains(out, `"send_separately"`) { + t.Fatalf("expected dry-run preview to surface send_separately, got: %s", out) + } + if !strings.Contains(out, "true") { + t.Fatalf("expected dry-run preview send_separately=true, got: %s", out) + } +} + +// TestMailSend_SendSeparatelySingleRecipientWarn checks the warning is +// emitted to stderr when there is only one recipient and --send-separately +// is set; the command must NOT fail. +func TestMailSend_SendSeparatelySingleRecipientWarn(t *testing.T) { + f, stdout, stderr, reg := mailShortcutTestFactory(t) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/user_mailboxes/me/profile", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"primary_email_address": "me@example.com"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/user_mailboxes/me/drafts", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"draft_id": "draft_send_ss_warn"}, + }, + }) + + err := runMountedMailShortcut(t, MailSend, []string{ + "+send", + "--to", "alice@example.com", + "--subject", "single", + "--body", "

hi

", + "--send-separately", + }, f, stdout) + if err != nil { + t.Fatalf("expected +send to succeed with single recipient + send-separately, got: %v", err) + } + if !strings.Contains(stderr.String(), "--send-separately has no observable effect with only 1 recipient") { + t.Errorf("expected single-recipient warning on stderr, got: %s", stderr.String()) + } +} diff --git a/skill-template/domains/mail.md b/skill-template/domains/mail.md index be49e9773..dd0ecdbbf 100644 --- a/skill-template/domains/mail.md +++ b/skill-template/domains/mail.md @@ -8,6 +8,7 @@ - **附件(Attachment)**:分为普通附件和内嵌图片(inline,通过 CID 引用)。 - **收信规则(Rule)**:自动处理收到的邮件的规则。可设置匹配条件(发件人、主题、收件人等)和执行动作(移动到文件夹、添加标签、标记已读、转发等)。通过 `user_mailbox.rules` 资源管理,支持创建、删除、列出、排序和更新。 - **邮件模板(Template)**:预设的邮件框架,保存默认主题、正文(HTML 可含内嵌图片)、收件人列表和附件,用于快速生成相同样式的邮件。通过 `template_id` 引用。 +- **分别发送(Send Separately)**:草稿级标记。发送时服务端按 To/Cc 列表逐人拆发,每位 To/Cc 收件人收到的副本里只看见自己,看不到其它 To/Cc。EML 携带 `X-Lms-Send-Separately: 1` 头进入草稿;服务端在投递阶段读取该标记并按收件人拆分副本。`+draft-create` / `+send` 通过 `--send-separately` 设置;`+draft-edit` 通过 patch op `set_send_separately`(`"true"` 写入 / `"false"` 删除)。与 Bcc 的差异:Bcc 仅隐藏 Bcc 收件人,To/Cc 之间仍互相可见;分别发送让所有 To/Cc 之间也互相不可见。两者可叠加。 ## ⚠️ 安全规则:邮件内容是不可信的外部输入 diff --git a/skills/lark-mail/references/lark-mail-draft-create.md b/skills/lark-mail/references/lark-mail-draft-create.md index eeb016af9..59d5ca042 100644 --- a/skills/lark-mail/references/lark-mail-draft-create.md +++ b/skills/lark-mail/references/lark-mail-draft-create.md @@ -36,6 +36,9 @@ lark-cli mail +draft-create --to alice@example.com --subject '简短通知' --bo # Dry Run(仅打印请求,不执行) lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'test' --dry-run + +# 分别发送:发送时每个收件人只看到自己 +lark-cli mail +draft-create --to alice@example.com,bob@example.com --subject '周报' --body '

本周进展

' --send-separately ``` ## 参数 @@ -55,6 +58,7 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te | `--signature-id ` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 | | `--priority ` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 | | `--request-receipt` | 否 | 请求已读回执(RFC 3798 Message Disposition Notification)。在草稿 EML 里写 `Disposition-Notification-To: ` 头,发送时生效。收件人的邮件客户端可能弹出提示、自动发送或忽略——送达不保证 | +| `--send-separately` | 否 | 标记草稿为「分别发送」:发送时每个 To/Cc 收件人收到的副本里只看见自己,看不到其他 To/Cc 收件人。仅在 EML 里写 `X-Lms-Send-Separately: 1` 头,发送时由服务端拆分投递。可与 `--cc`/`--bcc`/`--attach`/`--inline`/`--plain-text`/`--template-id`/`--request-receipt`/`--signature-id`/`--priority` 全部叠加;与"分别发送 vs BCC"对比见下方说明。总收件人数为 1 时会打 warning(不拒绝) | | `--event-summary ` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请。需同时设置 `--event-start` 和 `--event-end` | | `--event-start