mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
* feat(mail): HTML lint library + Larksuite-native autofix + lark-mail skill 为 lark-cli mail 域写信链路引入 HTML lint 能力,提升邮件 HTML 的兼容性、 安全性与 Larksuite-native 格式适配。 lint 库(shortcuts/mail/lint/): - 四档分类:pass / native-autofix / warn-autofix / error-strip - 安全规则覆盖 script / iframe / on* 事件处理器 / javascript: 及其它 危险 URL scheme 等 XSS 向量,未知 scheme 一律删除并归 error - Larksuite-native 格式自动修复:双层 div 段落、原生多级列表结构、 灰边引用、Larksuite 蓝链接 - cleaned_html 输出确定性稳定(位置索引派生 data-ol-id),便于 golden-file 测试与缓存 +lint-html 独立预检 shortcut: - 只读、不调 API、不建草稿,供 AI / 用户 / CI 在写信前预览 lint 结果 写入路径内置 lint(6 个 compose shortcut): - +send / +draft-create / +draft-edit / +reply / +reply-all / +forward 在 emlbuilder 之前强制 lint 净化 HTML - 默认 envelope 对 lint 改动透明(无 lint 字段),保持小巧供 AI 消费; --show-lint-details 显式取证返回 lint_applied[] / original_blocked[] - --body-file 支持从文件读取 body(32MB 上限),与 --body 互斥 预制 HTML 邮件模板(skills/lark-mail/assets/templates/): - 资讯周报 / 个人周报 / 团队周报 / 调研报告 / 求职简历 5 套 - 按 Larksuite mail-editor 原生格式编写,含正确的多级列表嵌套结构 lark-mail skill 文档: - references/lark-mail-html.md:邮件 HTML 写法指南(24 个格式 section + 颜色调色盘 + URL scheme + 官方模板套用流程) - references/lark-mail-lint-html.md:+lint-html 用法 - SKILL.md 顶部 CRITICAL 引导 * fix(mail): remove unused readAttr func and apply gofmt Drop the unused `readAttr` helper in shortcuts/mail/lint/linter.go that was flagged by golangci-lint (unused linter). Apply gofmt to linter.go and rules.go which had minor formatting issues. * fix(mail): address compose lint and guidance
665 lines
36 KiB
Go
665 lines
36 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: "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": "<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
|
|
}
|
|
// 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 <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 --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 <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",
|
|
"use --body <html> 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 <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
|
|
}
|