// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package mail import ( "context" "encoding/json" "fmt" "io" "strings" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" "github.com/larksuite/cli/shortcuts/mail/ics" ) // MailDraftEdit is the `+draft-edit` shortcut: update an existing draft // without sending it. Performs MIME-safe read/patch/write so unchanged // structure, attachments, and headers are preserved where possible. var MailDraftEdit = common.Shortcut{ Service: "mail", Command: "+draft-edit", Description: "Use when updating an existing mail draft without sending it. Prefer this shortcut over calling raw drafts.get or drafts.update directly, because it performs draft-safe MIME read/patch/write editing while preserving unchanged structure, attachments, and headers where possible.", Risk: "write", Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "from", Default: "me", Desc: "Mailbox email address containing the draft (default: me). Prefer --mailbox for clarity; --from is kept for backward compatibility."}, {Name: "mailbox", Desc: "Mailbox email address that owns the draft (default: falls back to --from, then me). Takes priority over --from when both are set."}, {Name: "draft-id", Desc: "Target draft ID. Required for real edits. It can be omitted only when using the --print-patch-template flag by itself."}, {Name: "set-subject", Desc: "Replace the subject with this final value. Use this for full subject replacement, not for appending a fragment to the existing subject."}, {Name: "set-to", Desc: "Replace the entire To recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."}, {Name: "set-cc", Desc: "Replace the entire Cc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."}, {Name: "set-bcc", Desc: "Replace the entire Bcc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."}, {Name: "body", Desc: "Full email body for a complete replacement (set_body). Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --patch-file with set_reply_body when you need to preserve an existing reply/forward quote block; use --body when you want a full body replacement. Mutually exclusive with --body-file. Cannot be combined with --patch-file body ops."}, bodyFileFlag, {Name: "patch-file", Desc: "Advanced edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. Use --body/--body-file for quick full-body replacement; use --patch-file with set_body/set_reply_body when you need typed body ops, especially set_reply_body to preserve an existing reply/forward quote block. Run --inspect first to check has_quoted_content, then --print-patch-template for the JSON structure. Relative path only."}, {Name: "print-patch-template", Type: "bool", Desc: "Print the JSON template and supported operations for the --patch-file flag. Recommended first step before generating a patch file. No draft read or write is performed."}, {Name: "set-priority", Desc: "Set email priority: high, normal, low. Setting 'normal' removes any existing priority header."}, {Name: "set-event-summary", Desc: "Set calendar event title. Must be used together with --set-event-start and --set-event-end."}, {Name: "set-event-start", Desc: "Set calendar event start time (ISO 8601)."}, {Name: "set-event-end", Desc: "Set calendar event end time (ISO 8601)."}, {Name: "set-event-location", Desc: "Set calendar event location."}, {Name: "remove-event", Type: "bool", Desc: "Remove the calendar event from the draft."}, {Name: "inspect", Type: "bool", Desc: "Inspect the draft without modifying it. Returns the draft projection including subject, recipients, body summary, has_quoted_content (whether the draft contains a reply/forward quote block), attachments_summary (with part_id and cid for each attachment), and inline_summary. Run this BEFORE editing body to check has_quoted_content: if true, use set_reply_body in --patch-file to preserve the quote; if false, use set_body."}, {Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the draft's sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed. Adds the Disposition-Notification-To header; existing value is overwritten."}, showLintDetailsFlag, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { if runtime.Bool("print-patch-template") { return common.NewDryRunAPI(). Set("mode", "print-patch-template"). Set("template", buildDraftEditPatchTemplate()) } draftID := runtime.Str("draft-id") if draftID == "" { return common.NewDryRunAPI().Set("error", "--draft-id is required for real draft edits; only --print-patch-template can be used without a draft id") } mailboxID := resolveComposeMailboxID(runtime) if runtime.Bool("inspect") { return common.NewDryRunAPI(). Desc("Inspect a draft without modifying it: fetch the raw EML, parse it into MIME structure, and return the projection (subject, recipients, body, attachments_summary, inline_summary). No write is performed."). GET(mailboxPath(mailboxID, "drafts", draftID)). Params(map[string]interface{}{"format": "raw"}) } patch, err := buildDraftEditPatch(runtime) if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } return common.NewDryRunAPI(). Desc("Edit an existing draft without sending it: first call drafts.get(format=raw) to fetch the current EML, parse it into MIME structure, apply either direct flags or the typed patch from patch-file, re-serialize the updated draft, and then call drafts.update. This is a minimal-edit pipeline rather than a full rebuild, so unchanged headers, attachments, and MIME subtrees are preserved where possible. Quick full-body replacement can use --body/--body-file; advanced body edits can use --patch-file with set_body or set_reply_body ops. It also has no optimistic locking, so concurrent edits to the same draft are last-write-wins."). GET(mailboxPath(mailboxID, "drafts", draftID)). Params(map[string]interface{}{"format": "raw"}). PUT(mailboxPath(mailboxID, "drafts", draftID)). Body(map[string]interface{}{ "raw": "", "_patch": patch.Summary(), "_notice": "This edit flow has no optimistic locking. If the same draft is changed concurrently, the last writer wins.", }) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { if runtime.Bool("print-patch-template") { runtime.Out(buildDraftEditPatchTemplate(), nil) return nil } draftID := runtime.Str("draft-id") if draftID == "" { return output.ErrValidation("--draft-id is required for real draft edits; if you only need a patch template, run with --print-patch-template") } mailboxID := resolveComposeMailboxID(runtime) if runtime.Bool("inspect") { return executeDraftInspect(runtime, mailboxID, draftID) } patch, err := buildDraftEditPatch(runtime) if err != nil { return err } rawDraft, err := draftpkg.GetRaw(runtime, mailboxID, draftID) if err != nil { return fmt.Errorf("read draft raw EML failed: %w", err) } snapshot, err := draftpkg.Parse(rawDraft) if err != nil { return output.ErrValidation("parse draft raw EML failed: %v", err) } // Pre-process ops that need snapshot context: resolve signature using // the draft's From address, and build ICS for set_calendar using the // draft's From/To/Cc so organizer and attendee addresses are correct. var draftFromEmail string if len(snapshot.From) > 0 { draftFromEmail = snapshot.From[0].Address } if err := requireSenderForRequestReceipt(runtime, draftFromEmail); err != nil { return err } if runtime.Bool("request-receipt") { // draftFromEmail comes from the existing draft's From header, // which could have been authored via a raw-EML path (IMAP APPEND, // OpenAPI drafts raw) and contain CR/LF or dangerous Unicode. // Going straight into PatchOp.Value would bypass emlbuilder's // validateHeaderValue gate, so repeat the check here explicitly. if err := validateHeaderAddress(draftFromEmail); err != nil { return output.ErrValidation( "cannot set --request-receipt: draft From address is unsafe for a header (%v)", err) } patch.Ops = append(patch.Ops, draftpkg.PatchOp{ Op: "set_header", Name: "Disposition-Notification-To", Value: "<" + draftFromEmail + ">", }) } for i := range patch.Ops { switch patch.Ops[i].Op { case "insert_signature": sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail) if sigErr != nil { return sigErr } if sigResult != nil { patch.Ops[i].RenderedSignatureHTML = sigResult.RenderedContent patch.Ops[i].SignatureImages = sigResult.Images } case "set_calendar": if calPart := draftpkg.FindPartByMediaType(snapshot.Body, "text/calendar"); calPart != nil { parsed := ics.ParseEvent(string(calPart.Body)) if parsed == nil || !parsed.IsLarkDraft { return output.ErrValidation("set_calendar: calendar event has already been created and is read-only; use --remove-event to remove it, then --set-event-* to create a new one") } } if _, _, err := parseEventTimeRange(patch.Ops[i].EventStart, patch.Ops[i].EventEnd); err != nil { return output.ErrValidation("set_calendar: %v", err) } // Derive effective To/Cc by replaying all pending recipient ops so // the ICS ATTENDEE list matches the final post-edit recipients. toAddrs, ccAddrs := effectiveRecipients(snapshot, patch.Ops) calData := buildCalendarBodyFromArgs( patch.Ops[i].EventSummary, patch.Ops[i].EventStart, patch.Ops[i].EventEnd, patch.Ops[i].EventLocation, draftFromEmail, joinAddresses(toAddrs), joinAddresses(ccAddrs), ) if calData == nil { return output.ErrValidation("set_calendar: failed to build ICS from event fields") } patch.Ops[i].CalendarICS = calData } } // Pre-process add_attachment ops for large attachment support: // extract oversized files, upload them, inject HTML into the snapshot body. patch, err = preprocessLargeAttachmentsForDraftEdit(ctx, runtime, snapshot, patch) if err != nil { return err } // Writing-path lint for body ops only: set_body / set_reply_body // rewrite the body field; other ops (set_subject / set_recipients / // add_attachment / etc.) operate on non-HTML fields and MUST NOT be // linted. Lint runs after loadPatchFile parses JSON and BEFORE // draftpkg.Apply writes into the snapshot. Each op's `value` is // replaced with the cleaned HTML in place; findings accumulate across // ops into a single per-patch report. lintApplied, lintBlocked := emptyLintEnvelopeFields() for i := range patch.Ops { op := &patch.Ops[i] if op.Op != "set_body" && op.Op != "set_reply_body" { continue } if op.Value == "" { continue } if !bodyIsHTML(op.Value) { // Plain-text body op — no lint pass needed (the HTML rule set // is irrelevant), but the envelope still surfaces empty arrays. continue } cleaned, rep := runWritePathLint(op.Value) op.Value = cleaned lintApplied = append(lintApplied, rep.Applied...) lintBlocked = append(lintBlocked, rep.Blocked...) } dctx := &draftpkg.DraftCtx{FIO: runtime.FileIO()} if len(patch.Ops) > 0 { if err := draftpkg.Apply(dctx, snapshot, patch); err != nil { return output.ErrValidation("apply draft patch failed: %v", err) } } serialized, err := draftpkg.Serialize(snapshot) if err != nil { return output.ErrValidation("serialize draft failed: %v", err) } updateResult, err := draftpkg.UpdateWithRaw(runtime, mailboxID, draftID, serialized) if err != nil { return fmt.Errorf("update draft failed: %w", err) } projection := draftpkg.Project(snapshot) out := map[string]interface{}{ "draft_id": updateResult.DraftID, "warning": "This edit flow has no optimistic locking. If the same draft is changed concurrently, the last writer wins.", "projection": projection, } if updateResult.Reference != "" { out["reference"] = updateResult.Reference } // Writing-path lint envelope: counts always present; full Finding // arrays only when the caller asked for them via --show-lint-details. applyLintToEnvelope(out, lintApplied, lintBlocked, runtime.Bool("show-lint-details")) addComposeHint(out) runtime.OutFormat(out, nil, func(w io.Writer) { fmt.Fprintln(w, "Draft updated.") fmt.Fprintf(w, "draft_id: %s\n", updateResult.DraftID) if reference, _ := out["reference"].(string); reference != "" { fmt.Fprintf(w, "reference: %s\n", reference) } if projection.Subject != "" { fmt.Fprintf(w, "subject: %s\n", sanitizeForTerminal(projection.Subject)) } if recipients := prettyDraftAddresses(projection.To); recipients != "" { fmt.Fprintf(w, "to: %s\n", sanitizeForTerminal(recipients)) } if projection.BodyText != "" { fmt.Fprintf(w, "body_text: %s\n", sanitizeForTerminal(projection.BodyText)) } if projection.BodyHTMLSummary != "" { fmt.Fprintf(w, "body_html_summary: %s\n", sanitizeForTerminal(projection.BodyHTMLSummary)) } if len(projection.AttachmentsSummary) > 0 { fmt.Fprintf(w, "attachments: %d\n", len(projection.AttachmentsSummary)) } if len(projection.InlineSummary) > 0 { fmt.Fprintf(w, "inline_parts: %d\n", len(projection.InlineSummary)) } if len(projection.Warnings) > 0 { fmt.Fprintf(w, "warnings: %s\n", sanitizeForTerminal(strings.Join(projection.Warnings, "; "))) } fmt.Fprintln(w, "warning: This edit flow has no optimistic locking. If the same draft is changed concurrently, the last writer wins.") }) return nil }, } // executeDraftInspect implements the +draft-edit --inspect path: it fetches // the raw EML, parses it into a MIME snapshot, and emits a draft projection // (subject, recipients, body summary, attachment / inline summaries) without // modifying the draft. func executeDraftInspect(runtime *common.RuntimeContext, mailboxID, draftID string) error { rawDraft, err := draftpkg.GetRaw(runtime, mailboxID, draftID) if err != nil { return fmt.Errorf("read draft raw EML failed: %w", err) } snapshot, err := draftpkg.Parse(rawDraft) if err != nil { return output.ErrValidation("parse draft raw EML failed: %v", err) } projection := draftpkg.Project(snapshot) out := map[string]interface{}{ "draft_id": draftID, "projection": projection, } runtime.OutFormat(out, nil, func(w io.Writer) { fmt.Fprintln(w, "Draft inspection (read-only, no changes applied).") fmt.Fprintf(w, "draft_id: %s\n", draftID) if projection.Subject != "" { fmt.Fprintf(w, "subject: %s\n", sanitizeForTerminal(projection.Subject)) } if recipients := prettyDraftAddresses(projection.To); recipients != "" { fmt.Fprintf(w, "to: %s\n", sanitizeForTerminal(recipients)) } if recipients := prettyDraftAddresses(projection.Cc); recipients != "" { fmt.Fprintf(w, "cc: %s\n", sanitizeForTerminal(recipients)) } if projection.BodyText != "" { fmt.Fprintf(w, "body_text: %s\n", sanitizeForTerminal(projection.BodyText)) } if projection.BodyHTMLSummary != "" { fmt.Fprintf(w, "body_html_summary: %s\n", sanitizeForTerminal(projection.BodyHTMLSummary)) } if projection.HasQuotedContent { fmt.Fprintln(w, "has_quoted_content: true (use set_reply_body op in --patch-file to edit body while preserving the quote)") } if len(projection.AttachmentsSummary) > 0 { fmt.Fprintf(w, "attachments (%d):\n", len(projection.AttachmentsSummary)) for _, att := range projection.AttachmentsSummary { fmt.Fprintf(w, " - part_id=%s filename=%s content_type=%s cid=%s\n", att.PartID, att.FileName, att.ContentType, att.CID) } } if len(projection.LargeAttachmentsSummary) > 0 { fmt.Fprintf(w, "large_attachments (%d):\n", len(projection.LargeAttachmentsSummary)) for _, att := range projection.LargeAttachmentsSummary { fmt.Fprintf(w, " - token=%s filename=%s size_bytes=%d\n", att.Token, att.FileName, att.SizeBytes) } } if len(projection.InlineSummary) > 0 { fmt.Fprintf(w, "inline_parts (%d):\n", len(projection.InlineSummary)) for _, inl := range projection.InlineSummary { fmt.Fprintf(w, " - part_id=%s filename=%s content_type=%s cid=%s\n", inl.PartID, inl.FileName, inl.ContentType, inl.CID) } } if len(projection.Warnings) > 0 { fmt.Fprintf(w, "warnings: %s\n", sanitizeForTerminal(strings.Join(projection.Warnings, "; "))) } if projection.Priority != "" { fmt.Fprintf(w, "priority: %s\n", sanitizeForTerminal(projection.Priority)) } }) return nil } // prettyDraftAddresses renders a list of draft addresses as a comma-separated // string suitable for stderr human output. Returns "" for an empty list. func prettyDraftAddresses(addrs []draftpkg.Address) string { if len(addrs) == 0 { return "" } parts := make([]string, 0, len(addrs)) for _, addr := range addrs { parts = append(parts, addr.String()) } return strings.Join(parts, ", ") } // buildDraftEditPatch assembles a draftpkg.Patch from the runtime flags: // direct flags (--set-subject / --set-to / --set-cc / --set-bcc / // --set-priority) become Ops, and --patch-file is loaded and merged. // Returns ErrValidation when neither direct flags nor --patch-file produce // any operations. func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error) { patch := draftpkg.Patch{ Options: draftpkg.PatchOptions{ AllowProtectedHeaderEdits: runtime.Bool("allow-protected-header-edit"), RewriteEntireDraft: runtime.Bool("rewrite-entire-draft"), }, } patchFile := strings.TrimSpace(runtime.Str("patch-file")) if patchFile != "" { filePatch, err := loadPatchFile(runtime, patchFile) if err != nil { return patch, err } patch.Ops = append(patch.Ops, filePatch.Ops...) if filePatch.Options.AllowProtectedHeaderEdits { patch.Options.AllowProtectedHeaderEdits = true } if filePatch.Options.RewriteEntireDraft { patch.Options.RewriteEntireDraft = true } } setRecipients := func(field, raw string) { if strings.TrimSpace(raw) == "" { return } addrs := parseNetAddrs(raw) opAddrs := make([]draftpkg.Address, 0, len(addrs)) for _, addr := range addrs { opAddrs = append(opAddrs, draftpkg.Address{ Name: addr.Name, Address: addr.Address, }) } patch.Ops = append(patch.Ops, draftpkg.PatchOp{ Op: "set_recipients", Field: field, Addresses: opAddrs, }) } if value := strings.TrimSpace(runtime.Str("set-subject")); value != "" { patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "set_subject", Value: value}) } if err := validateRecipientCount(runtime.Str("set-to"), runtime.Str("set-cc"), runtime.Str("set-bcc")); err != nil { return patch, err } setRecipients("to", runtime.Str("set-to")) setRecipients("cc", runtime.Str("set-cc")) setRecipients("bcc", runtime.Str("set-bcc")) // --body / --body-file are convenience shorthands for a set_body patch // op. They cannot be combined with --patch-file body ops // (set_body / set_reply_body) to avoid ambiguous ordering. bodyFlag := runtime.Str("body") bodyFile := strings.TrimSpace(runtime.Str("body-file")) if err := validateBodyFileMutex(bodyFlag, bodyFile, runtime.ValidatePath); err != nil { return patch, err } bodyVal := bodyFlag if bodyVal == "" && bodyFile != "" { loaded, err := readBodyFile(runtime.FileIO(), bodyFile) if err != nil { return patch, err } bodyVal = loaded } if bodyVal != "" { for _, op := range patch.Ops { if op.Op == "set_body" || op.Op == "set_reply_body" { return patch, output.ErrValidation("--body / --body-file and --patch-file body ops (set_body/set_reply_body) are mutually exclusive; use one or the other") } } patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "set_body", Value: bodyVal}) } // --set-priority → inject set_header / remove_header op if setPriority := runtime.Str("set-priority"); setPriority != "" { headerVal, pErr := parsePriority(setPriority) if pErr != nil { return patch, pErr } if headerVal != "" { patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "set_header", Name: "X-Cli-Priority", Value: headerVal}) } else { patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "remove_header", Name: "X-Cli-Priority"}) } } // --set-event-* / --remove-event → set_calendar / remove_calendar op. // The ICS blob itself is pre-built at Execute time once the snapshot's // organizer/attendee addresses are available; here we only record the // user-supplied fields and validate the flag combination. hasEventSet := runtime.Str("set-event-summary") != "" hasEventRemove := runtime.Bool("remove-event") if !hasEventSet && (runtime.Str("set-event-start") != "" || runtime.Str("set-event-end") != "" || runtime.Str("set-event-location") != "") { return patch, output.ErrValidation("--set-event-start, --set-event-end, and --set-event-location require --set-event-summary") } if hasEventSet && hasEventRemove { return patch, output.ErrValidation("--set-event-summary and --remove-event are mutually exclusive") } if hasEventSet { summary := runtime.Str("set-event-summary") start := runtime.Str("set-event-start") end := runtime.Str("set-event-end") if summary == "" || start == "" || end == "" { return patch, output.ErrValidation("--set-event-summary, --set-event-start, and --set-event-end must all be provided together") } if _, _, err := parseEventTimeRange(start, end); err != nil { return patch, output.ErrValidation("%s", prefixEventRangeError("--set-event-", err).Error()) } patch.Ops = append(patch.Ops, draftpkg.PatchOp{ Op: "set_calendar", EventSummary: summary, EventStart: start, EventEnd: end, EventLocation: runtime.Str("set-event-location"), }) } else if hasEventRemove { patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "remove_calendar"}) } if len(patch.Ops) == 0 && !runtime.Bool("request-receipt") { return patch, output.ErrValidation("at least one edit operation is required; use direct flags such as --set-subject/--set-to, or use --patch-file for body edits and other advanced operations (run --print-patch-template first)") } if len(patch.Ops) == 0 { // --request-receipt only: Validate() would reject empty Ops, so skip it // here. The Disposition-Notification-To op is appended in Execute once // the draft's From address is known. return patch, nil } return patch, patch.Validate() } // loadPatchFile reads and JSON-decodes a patch file from a relative path // rooted at the runtime's FileIO. Returns ErrValidation on read or parse // errors so the caller can surface a user-friendly message without leaking // internal stack traces. func loadPatchFile(runtime *common.RuntimeContext, path string) (draftpkg.Patch, error) { var patch draftpkg.Patch f, err := runtime.FileIO().Open(path) if err != nil { return patch, fmt.Errorf("--patch-file %q: %w", path, err) } defer f.Close() data, err := io.ReadAll(f) if err != nil { return patch, err } if err := json.Unmarshal(data, &patch); err != nil { return patch, fmt.Errorf("parse patch file: %w", err) } return patch, patch.Validate() } // buildDraftEditPatchTemplate returns the JSON template emitted by // --print-patch-template. It documents the supported ops and field shapes so // callers can author a --patch-file without having to read this file's source. func buildDraftEditPatchTemplate() map[string]interface{} { return map[string]interface{}{ "description": "Typed patch JSON for `mail +draft-edit --patch-file`. This is not RFC 6902 JSON Patch.", "template": map[string]interface{}{ "ops": []map[string]interface{}{ {"op": "set_subject", "value": "Updated subject"}, {"op": "set_recipients", "field": "to", "addresses": []map[string]interface{}{{"address": "alice@example.com", "name": "Alice"}}}, {"op": "set_body", "value": "Updated body"}, }, "options": map[string]interface{}{ "rewrite_entire_draft": false, "allow_protected_header_edits": false, }, }, "options_help": map[string]interface{}{ "rewrite_entire_draft": "Default false. Set to true only when the edit must synthesize or restructure body parts, for example adding a missing primary body part.", "allow_protected_header_edits": "Default false. Set to true only when the user explicitly wants to edit protected headers and understands the threading or delivery risk.", }, "supported_ops": []map[string]interface{}{ {"op": "set_subject", "shape": map[string]interface{}{"value": "string"}}, {"op": "set_recipients", "shape": map[string]interface{}{"field": "to|cc|bcc", "addresses": []map[string]interface{}{{"address": "string", "name": "string(optional)"}}}}, {"op": "add_recipient", "shape": map[string]interface{}{"field": "to|cc|bcc", "address": "string", "name": "string(optional)"}}, {"op": "remove_recipient", "shape": map[string]interface{}{"field": "to|cc|bcc", "address": "string"}}, {"op": "set_body", "shape": map[string]interface{}{"value": "string (supports — local paths auto-resolved to inline MIME parts)"}}, {"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": "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"}, {"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}}, {"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, {"op": "insert_signature", "shape": map[string]interface{}{"signature_id": "string (run mail +signature to list IDs)"}}, {"op": "remove_signature", "shape": map[string]interface{}{}, "note": "removes existing signature from the HTML body"}, }, "supported_ops_by_group": []map[string]interface{}{ { "group": "subject_and_body", "ops": []map[string]interface{}{ {"op": "set_subject", "shape": map[string]interface{}{"value": "string"}}, {"op": "set_body", "shape": map[string]interface{}{"value": "string (supports — local paths auto-resolved to inline MIME parts)"}}, {"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)"}}, }, }, { "group": "recipients", "ops": []map[string]interface{}{ {"op": "set_recipients", "shape": map[string]interface{}{"field": "to|cc|bcc", "addresses": []map[string]interface{}{{"address": "string", "name": "string(optional)"}}}}, {"op": "add_recipient", "shape": map[string]interface{}{"field": "to|cc|bcc", "address": "string", "name": "string(optional)"}}, {"op": "remove_recipient", "shape": map[string]interface{}{"field": "to|cc|bcc", "address": "string"}}, }, }, { "group": "headers", "ops": []map[string]interface{}{ {"op": "set_header", "shape": map[string]interface{}{"name": "string", "value": "string"}}, {"op": "remove_header", "shape": map[string]interface{}{"name": "string"}}, }, }, { "group": "attachments_and_inline", "ops": []map[string]interface{}{ {"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"}, {"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}}, {"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, }, }, { "group": "signature", "ops": []map[string]interface{}{ {"op": "insert_signature", "shape": map[string]interface{}{"signature_id": "string (run mail +signature to list IDs)"}}, {"op": "remove_signature", "shape": map[string]interface{}{}, "note": "removes existing signature and its preceding spacing from the HTML body"}, }, }, }, "recommended_usage": []string{ "Use direct flags (--set-subject, --set-to, --set-cc, --set-bcc) for simple metadata edits", "Use --body/--body-file for quick full-body replacement; use --patch-file for advanced body edits and advanced changes (recipients, headers, attachments, inline images)", "Before editing body, run --inspect to check has_quoted_content; if true, use set_reply_body instead of set_body", }, "body_edit_decision_guide": []map[string]interface{}{ {"situation": "plain draft or non-reply/forward draft", "recommended_op": "set_body — replaces user-authored content; signature/attachments auto-preserved"}, {"situation": "draft has both text/plain and text/html", "recommended_op": "set_body — updates HTML body and regenerates plain-text summary; pass HTML input because the original main body is text/html"}, {"situation": "draft created by +reply or +forward (has_quoted_content=true)", "recommended_op": "set_reply_body — replaces only the user-authored portion; quote block, signature, and attachments are automatically preserved. Use set_body if user explicitly wants to remove or modify the quote"}, }, "notes": []string{ "`set_body`/`set_reply_body` support inline images via local file paths: use in the HTML value — the local path is automatically resolved into an inline MIME part with a generated CID; removing or replacing an tag automatically cleans up or replaces the corresponding MIME part; do NOT use `add_inline` for this; example: {\"op\":\"set_body\",\"value\":\"
Hello
\"}", "`add_inline` is an advanced op for precise CID control only — in most cases, use in `set_body`/`set_reply_body` instead", "`ops` is executed in order", "all file paths (--patch-file and `path` fields in ops) must be relative — no absolute paths or .. traversal", "use --body for a quick full-body replacement (equivalent to a set_body op); use --patch-file with set_body/set_reply_body for advanced body edits; --body and --patch-file body ops are mutually exclusive", "`set_body` replaces the user-authored content. It does NOT auto-preserve the old quote block (include one in value if needed, or use `set_reply_body`). Signature, large attachment card, and normal attachment MIME parts are auto-preserved. When the draft has both text/plain and text/html, it updates the HTML body and regenerates the plain-text summary, so the input should be HTML.", "`set_reply_body` replaces only the user-authored portion of the body and automatically re-appends the trailing reply/forward quote block, signature, and large attachment card; the value you pass should contain ONLY the new user-authored content (no quote, no signature, no attachment card). If the user wants to modify content INSIDE the quote block, use `set_body` instead. If the draft has no quote block, it behaves identically to `set_body`.", "`body_kind` only supports text/plain and text/html", "`selector` currently only supports primary", "`remove_attachment` target supports part_id (normal attachment), cid (normal attachment), or token (large attachment); priority: part_id > cid > token", "Large attachments are located by token (not part_id/cid). Get tokens from `--inspect`'s `large_attachments_summary`.", "`set_body` and `set_reply_body` automatically preserve signature block and all attachments (normal + large) from the old body. To delete signature/attachments use the dedicated ops: remove_signature, remove_attachment.", "`remove_attachment`/`remove_inline` require part_id or cid; to discover these values, run `+draft-edit --draft-id --inspect` first — the response `projection.attachments_summary` and `projection.inline_summary` list every part with its part_id, cid, and filename", "`add_inline`/`replace_inline`/`remove_inline` are for CID-based inline images", "`replace_inline` keeps the original filename and content_type when those fields are omitted", "protected headers require `allow_protected_header_edits=true`", "--set-priority high|normal|low controls draft priority via X-Cli-Priority header (CLI/OAPI specific). high → set_header X-Cli-Priority=1; low → set_header X-Cli-Priority=5; normal → remove_header X-Cli-Priority. Backend mail-data-access headersToPbBodyExtra recognizes X-Cli-Priority but not standard X-Priority/Importance for OAPI flow.", }, "command_example": "lark-cli mail +draft-edit --print-patch-template", "patch_file_example": "lark-cli mail +draft-edit --draft-id d_xxx --patch-file ./patch.json", } } // effectiveRecipients returns the To and Cc address slices that will result // after all pending set_recipients / add_recipient / remove_recipient ops in // ops have been applied. Used by the set_calendar pre-processor to build ICS // with the correct post-edit ATTENDEE list before Apply() runs. func effectiveRecipients(snapshot *draftpkg.DraftSnapshot, ops []draftpkg.PatchOp) (to, cc []draftpkg.Address) { to = append([]draftpkg.Address{}, snapshot.To...) cc = append([]draftpkg.Address{}, snapshot.Cc...) apply := func(addrs []draftpkg.Address, op draftpkg.PatchOp) []draftpkg.Address { switch op.Op { case "set_recipients": return append([]draftpkg.Address{}, op.Addresses...) case "add_recipient": for _, a := range addrs { if strings.EqualFold(a.Address, op.Address) { return addrs } } return append(addrs, draftpkg.Address{Name: op.Name, Address: op.Address}) case "remove_recipient": next := addrs[:0:0] for _, a := range addrs { if !strings.EqualFold(a.Address, op.Address) { next = append(next, a) } } return next } return addrs } for _, op := range ops { switch op.Field { case "to": to = apply(to, op) case "cc": cc = apply(cc, op) } } return to, cc }