-
Notifications
You must be signed in to change notification settings - Fork 787
feat(mail): support --send-separately and set_send_separately patch op #835
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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\":\"<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, | ||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||
|
|
@@ -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") | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+177
to
+180
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| } | ||||||||||||||||||
| 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) | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tighten errno
6002matching 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
📝 Committable suggestion
🤖 Prompt for AI Agents