Files
larksuite-cli/shortcuts/mail/mail_draft_edit.go
xzcong0820 e511404065 feat(mail): expose draft priority in --inspect projection and document --set-priority (#779)
Add a Priority field to DraftProjection populated from the EML header pair
X-Cli-Priority (CLI/OAPI primary) → X-Priority (RFC fallback for IMAP-回灌
historical drafts), with case-insensitive lookup via the existing
headerValue helper and a local mapping table aligned with the backend
gopkg/mail_priority.PriorityValueToType vocabulary. When neither header is
present (the symmetric read of --set-priority normal=remove_header) the
projection emits "unknown" so agents have a stable read-side surface.

Append one notes entry to buildDraftEditPatchTemplate documenting the
--set-priority flag and the X-Cli-Priority translation contract.

The write-side (--set-priority flag, parsePriority helper, translation
branch in mail_draft_edit.go, EML header target) is unchanged — already
shipped on master.

sprint: S4
2026-05-19 14:02:01 +08:00

607 lines
33 KiB
Go

// 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: "patch-file", Desc: "Edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. All body edits MUST go through --patch-file. Two body ops: set_body (full replacement including quote) and set_reply_body (replaces only user-authored content, auto-preserves 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."},
},
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. Body edits must go through --patch-file using 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": "<base64url-EML>",
"_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
}
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
}
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"))
// --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 <img src=\"./local/path.png\" /> — 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 <img src=\"./local/path.png\" /> — 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 <img src=\"./path\"> 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 <img src=\"./local/path.png\" /> — 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 <img src=\"./local/path.png\" /> — 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 <img src=\"./path\"> 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 --patch-file for ALL 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 <img src=\"./local/file.png\" /> in the HTML value — the local path is automatically resolved into an inline MIME part with a generated CID; removing or replacing an <img> tag automatically cleans up or replaces the corresponding MIME part; do NOT use `add_inline` for this; example: {\"op\":\"set_body\",\"value\":\"<div>Hello<img src=\\\"./logo.png\\\" /></div>\"}",
"`add_inline` is an advanced op for precise CID control only — in most cases, use <img src=\"./path\"> 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",
"all body edits MUST go through --patch-file; there is no --set-body flag",
"`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 <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
}