mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
* feat(mail): bot+mailbox=me validation and dynamic --as help tests Add validateBotMailboxNotMe helper to shortcuts/mail/helpers.go and wire it as a Validate callback into +message, +messages, +thread and +triage, so bot identity combined with the default --mailbox me is rejected early with a clear fixup hint instead of a late opaque API error. The --as help text was already dynamic via AddShortcutIdentityFlag; add TC-10/TC-11 tests in internal/cmdutil/identity_flag_test.go to pin that behaviour, and TC-1 through TC-9 in shortcuts/mail/mail_shortcut_validation_test.go to cover the new Validate callbacks. +watch is excluded: its AuthTypes is ["user"], so bot is never valid. sprint: S2 * test(cmdutil): add Hidden and DefValue assertions to identity flag tests * fix(mail): add bot+mailbox=me validation to +template-create and +template-update * fix(mail): add bot+mailbox=me validation to +template-update * fix(mail): gofmt mail_template_create.go * fix(mail): gofmt mail_template_update.go * fix(mail): skip bot+mailbox=me check for print-patch-template local path
378 lines
16 KiB
Go
378 lines
16 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"
|
|
)
|
|
|
|
var MailTemplateUpdate = common.Shortcut{
|
|
Service: "mail",
|
|
Command: "+template-update",
|
|
Description: "Update an existing mail template. Supports --inspect (read-only projection), --print-patch-template (prints a JSON skeleton for --patch-file), and flat flags (--set-subject / --set-name / etc). Internally it GETs the template, applies the patch, rewrites <img> local paths to cid: refs, and PUTs a full-replace update (no optimistic locking: last-write-wins).",
|
|
Risk: "write",
|
|
Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox:readonly"},
|
|
AuthTypes: []string{"user", "bot"},
|
|
HasFormat: true,
|
|
Flags: []common.Flag{
|
|
{Name: "mailbox", Desc: "Mailbox email address that owns the template (default: me)."},
|
|
{Name: "template-id", Desc: "Template ID to update. Required except when using --print-patch-template by itself.", Required: false},
|
|
{Name: "inspect", Type: "bool", Desc: "Inspect the template without modifying it. Returns the current template projection (name/subject/addresses/attachments). No write is performed."},
|
|
{Name: "print-patch-template", Type: "bool", Desc: "Print a JSON template describing the supported --patch-file structure. No network call is made."},
|
|
{Name: "patch-file", Desc: "Path to a JSON patch file (relative path only). Shape is the same as --print-patch-template output."},
|
|
{Name: "set-name", Desc: "Replace the template name (≤100 chars)."},
|
|
{Name: "set-subject", Desc: "Replace the template subject."},
|
|
{Name: "set-template-content", Desc: "Replace the template body content. Prefer HTML for rich formatting."},
|
|
{Name: "set-template-content-file", Desc: "Replace template body content with the contents of a file (relative path only). Mutually exclusive with --set-template-content."},
|
|
{Name: "set-plain-text", Type: "bool", Desc: "Set is_plain_text_mode=true."},
|
|
{Name: "set-to", Desc: "Replace the To recipient list. Separate multiple addresses with commas. Pass --set-to=\"\" to clear the list."},
|
|
{Name: "set-cc", Desc: "Replace the Cc recipient list. Pass --set-cc=\"\" to clear the list."},
|
|
{Name: "set-bcc", Desc: "Replace the Bcc recipient list. Pass --set-bcc=\"\" to clear the list."},
|
|
{Name: "attach", Desc: "Additional non-inline attachment file path(s), comma-separated. Each file is uploaded to Drive and appended to the template's attachments[] in the exact flag order."},
|
|
},
|
|
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", buildTemplatePatchSkeleton())
|
|
}
|
|
mailboxID := resolveComposeMailboxID(runtime)
|
|
tid := runtime.Str("template-id")
|
|
if tid == "" {
|
|
return common.NewDryRunAPI().Set("error", "--template-id is required except with --print-patch-template")
|
|
}
|
|
if runtime.Bool("inspect") {
|
|
return common.NewDryRunAPI().
|
|
Desc("Inspect the template without modifying it.").
|
|
GET(templateMailboxPath(mailboxID, tid))
|
|
}
|
|
api := common.NewDryRunAPI().
|
|
Desc("Update an existing mail template: GET the template, apply --set-* / --patch-file / --attach changes, upload any new local <img> references and --attach files to Drive, rewrite HTML to cid: references, and PUT a full-replace payload. The template endpoints have no optimistic locking; concurrent updates are last-write-wins.").
|
|
GET(templateMailboxPath(mailboxID, tid))
|
|
content, _, _ := resolveTemplateUpdateContent(runtime)
|
|
for _, img := range parseLocalImgs(content) {
|
|
addTemplateUploadSteps(runtime, api, img.Path)
|
|
}
|
|
for _, p := range splitByComma(runtime.Str("attach")) {
|
|
addTemplateUploadSteps(runtime, api, p)
|
|
}
|
|
api = api.PUT(templateMailboxPath(mailboxID, tid)).
|
|
Body(map[string]interface{}{
|
|
"template": "<merged from GET + patch flags>",
|
|
"_warning": "No optimistic locking — last write wins.",
|
|
})
|
|
logTemplateInfo(runtime, "update.dry_run", map[string]interface{}{
|
|
"mailbox_id": mailboxID,
|
|
"template_id": tid,
|
|
"is_plain_text_mode": runtime.Bool("set-plain-text"),
|
|
"name_len": len([]rune(runtime.Str("set-name"))),
|
|
"attachments_total": len(splitByComma(runtime.Str("attach"))) + len(parseLocalImgs(content)),
|
|
"inline_count": len(parseLocalImgs(content)),
|
|
"tos_count": countAddresses(runtime.Str("set-to")),
|
|
"ccs_count": countAddresses(runtime.Str("set-cc")),
|
|
"bccs_count": countAddresses(runtime.Str("set-bcc")),
|
|
})
|
|
return api
|
|
},
|
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
if runtime.Bool("print-patch-template") {
|
|
return nil
|
|
}
|
|
if err := validateBotMailboxNotMe(runtime); err != nil {
|
|
return err
|
|
}
|
|
if err := validateTemplateID(runtime.Str("template-id")); err != nil {
|
|
return err
|
|
}
|
|
if runtime.Str("template-id") == "" {
|
|
return output.ErrValidation("--template-id is required (or use --print-patch-template to print the patch skeleton)")
|
|
}
|
|
if runtime.Str("set-template-content") != "" && runtime.Str("set-template-content-file") != "" {
|
|
return output.ErrValidation("--set-template-content and --set-template-content-file are mutually exclusive")
|
|
}
|
|
if name := runtime.Str("set-name"); name != "" && len([]rune(name)) > 100 {
|
|
return output.ErrValidation("--set-name must be at most 100 characters")
|
|
}
|
|
return nil
|
|
},
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
if runtime.Bool("print-patch-template") {
|
|
runtime.Out(buildTemplatePatchSkeleton(), nil)
|
|
return nil
|
|
}
|
|
mailboxID := resolveComposeMailboxID(runtime)
|
|
tid := runtime.Str("template-id")
|
|
|
|
tpl, err := fetchTemplate(runtime, mailboxID, tid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if runtime.Bool("inspect") {
|
|
out := map[string]interface{}{"template": tpl}
|
|
runtime.OutFormat(out, nil, func(w io.Writer) {
|
|
fmt.Fprintln(w, "Template inspection (read-only).")
|
|
if tpl != nil {
|
|
fmt.Fprintf(w, "template_id: %s\n", tpl.TemplateID)
|
|
fmt.Fprintf(w, "name: %s\n", tpl.Name)
|
|
if tpl.Subject != "" {
|
|
fmt.Fprintf(w, "subject: %s\n", tpl.Subject)
|
|
}
|
|
fmt.Fprintf(w, "is_plain_text_mode: %v\n", tpl.IsPlainTextMode)
|
|
fmt.Fprintf(w, "attachments: %d\n", len(tpl.Attachments))
|
|
}
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// Apply flat --set-* flags.
|
|
if v := runtime.Str("set-name"); v != "" {
|
|
tpl.Name = v
|
|
}
|
|
if v := runtime.Str("set-subject"); v != "" {
|
|
tpl.Subject = v
|
|
}
|
|
newContent, _, err := resolveTemplateUpdateContent(runtime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
contentChanged := false
|
|
if newContent != "" {
|
|
tpl.TemplateContent = newContent
|
|
contentChanged = true
|
|
}
|
|
if runtime.Bool("set-plain-text") {
|
|
tpl.IsPlainTextMode = true
|
|
}
|
|
// Use Changed() so an explicit empty value (--set-to="") clears the
|
|
// list. The non-empty-only check would treat clear and "not provided"
|
|
// the same and silently drop the clear.
|
|
if runtime.Changed("set-to") {
|
|
tpl.Tos = renderTemplateAddresses(runtime.Str("set-to"))
|
|
}
|
|
if runtime.Changed("set-cc") {
|
|
tpl.Ccs = renderTemplateAddresses(runtime.Str("set-cc"))
|
|
}
|
|
if runtime.Changed("set-bcc") {
|
|
tpl.Bccs = renderTemplateAddresses(runtime.Str("set-bcc"))
|
|
}
|
|
|
|
// Apply JSON patch file (simple shallow merge). This is a convenience
|
|
// for agents that want to assemble updates off-line; the CLI simply
|
|
// overlays non-empty values onto the fetched template.
|
|
if pf := strings.TrimSpace(runtime.Str("patch-file")); pf != "" {
|
|
f, err := runtime.FileIO().Open(pf)
|
|
if err != nil {
|
|
return output.ErrValidation("open --patch-file %s: %v", pf, err)
|
|
}
|
|
buf, readErr := io.ReadAll(f)
|
|
f.Close()
|
|
if readErr != nil {
|
|
return output.ErrValidation("read --patch-file %s: %v", pf, readErr)
|
|
}
|
|
var patch templatePatchFile
|
|
if err := json.Unmarshal(buf, &patch); err != nil {
|
|
return output.ErrValidation("parse --patch-file %s: %v", pf, err)
|
|
}
|
|
if patch.TemplateContent != nil {
|
|
contentChanged = true
|
|
}
|
|
applyTemplatePatchFile(tpl, &patch)
|
|
}
|
|
|
|
// Apply plain-text → HTML line-break upgrade to newly supplied content
|
|
// so template preview renders line breaks the same way a draft composed
|
|
// from this template would render after sending. Only transform when
|
|
// this update call actually changed the content: if the user left the
|
|
// body alone, we must not re-wrap what the server already stored (doing
|
|
// so would double-wrap existing HTML bodies on every update).
|
|
if contentChanged {
|
|
tpl.TemplateContent = wrapTemplateContentIfNeeded(tpl.TemplateContent, tpl.IsPlainTextMode)
|
|
}
|
|
if int64(len(tpl.TemplateContent)) > maxTemplateContentBytes {
|
|
return output.ErrValidation("template content exceeds %d MB (got %.1f MB)",
|
|
maxTemplateContentBytes/(1024*1024),
|
|
float64(len(tpl.TemplateContent))/1024/1024)
|
|
}
|
|
|
|
// Re-resolve <img> references against the (possibly updated) content.
|
|
rewritten, newAtts, err := buildTemplatePayloadFromFlags(
|
|
ctx, runtime, tpl.Name, tpl.Subject, tpl.TemplateContent,
|
|
tpl.Tos, tpl.Ccs, tpl.Bccs,
|
|
splitByComma(runtime.Str("attach")),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tpl.TemplateContent = rewritten
|
|
// When the body changed, drop existing inline attachments whose CID
|
|
// is no longer referenced in the new template_content. Otherwise
|
|
// every <img> replace/delete leaves an orphan Drive-backed row
|
|
// behind and the template eventually trips TemplateTotalSizeLimit.
|
|
// Non-inline attachments are kept regardless because they aren't
|
|
// addressed via cid: refs. Skipped when the body wasn't touched —
|
|
// the existing cid: refs in the stored content still reference all
|
|
// existing inline rows, so removing any would break the template.
|
|
if contentChanged {
|
|
kept := tpl.Attachments[:0]
|
|
for _, a := range tpl.Attachments {
|
|
if a.IsInline && a.CID != "" && !strings.Contains(tpl.TemplateContent, "cid:"+a.CID) {
|
|
continue
|
|
}
|
|
kept = append(kept, a)
|
|
}
|
|
tpl.Attachments = kept
|
|
}
|
|
// Merge: keep existing template attachments (already uploaded, have
|
|
// file_keys), append newly uploaded ones. The EML-size/LARGE switch
|
|
// applies independently per call because this is a full-replace PUT.
|
|
//
|
|
// Dedup by (ID, CID) so repeated `+template-update --attach foo.png`
|
|
// runs don't accumulate duplicate rows (same Drive file_key /
|
|
// same inline cid); the first occurrence wins.
|
|
seenAttKey := make(map[string]bool, len(tpl.Attachments))
|
|
attKey := func(a templateAttachment) string { return a.ID + "|" + a.CID }
|
|
for _, a := range tpl.Attachments {
|
|
seenAttKey[attKey(a)] = true
|
|
}
|
|
for _, a := range newAtts {
|
|
if seenAttKey[attKey(a)] {
|
|
continue
|
|
}
|
|
seenAttKey[attKey(a)] = true
|
|
tpl.Attachments = append(tpl.Attachments, a)
|
|
}
|
|
// Server rejects the PUT with errno 99992402
|
|
// `template.attachments[*].body is required` when any entry's
|
|
// `body` field is empty. Fetched entries may round-trip without
|
|
// the body populated (the GET response omits raw bytes). Re-fill
|
|
// body from the file_key (which the backend resolves identically)
|
|
// so full-replace updates survive the required-field check.
|
|
for i := range tpl.Attachments {
|
|
if tpl.Attachments[i].Body == "" {
|
|
tpl.Attachments[i].Body = tpl.Attachments[i].ID
|
|
}
|
|
}
|
|
|
|
inlineCount, largeCount := countAttachmentsByType(tpl.Attachments)
|
|
logTemplateInfo(runtime, "update.execute", map[string]interface{}{
|
|
"mailbox_id": mailboxID,
|
|
"template_id": tid,
|
|
"is_plain_text_mode": tpl.IsPlainTextMode,
|
|
"name_len": len([]rune(tpl.Name)),
|
|
"attachments_total": len(tpl.Attachments),
|
|
"inline_count": inlineCount,
|
|
"large_count": largeCount,
|
|
"tos_count": len(tpl.Tos),
|
|
"ccs_count": len(tpl.Ccs),
|
|
"bccs_count": len(tpl.Bccs),
|
|
})
|
|
|
|
resp, err := updateTemplate(runtime, mailboxID, tid, tpl)
|
|
if err != nil {
|
|
return fmt.Errorf("update template failed: %w", err)
|
|
}
|
|
updated, _ := extractTemplatePayload(resp)
|
|
out := map[string]interface{}{
|
|
"template": updated,
|
|
"warning": "Template endpoints have no optimistic locking; concurrent updates are last-write-wins.",
|
|
}
|
|
runtime.OutFormat(out, nil, func(w io.Writer) {
|
|
fmt.Fprintln(w, "Template updated (last-write-wins; concurrent writers may overwrite each other).")
|
|
if updated != nil {
|
|
fmt.Fprintf(w, "template_id: %s\n", updated.TemplateID)
|
|
fmt.Fprintf(w, "name: %s\n", updated.Name)
|
|
fmt.Fprintf(w, "attachments: %d\n", len(updated.Attachments))
|
|
}
|
|
})
|
|
fmt.Fprintln(runtime.IO().ErrOut,
|
|
"warning: template endpoints have no optimistic locking; concurrent updates are last-write-wins.")
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// resolveTemplateUpdateContent returns the override body content from
|
|
// --set-template-content / --set-template-content-file. Empty string means
|
|
// the caller should keep the existing template_content.
|
|
func resolveTemplateUpdateContent(runtime *common.RuntimeContext) (content, sourcePath string, err error) {
|
|
if raw := runtime.Str("set-template-content"); raw != "" {
|
|
return raw, "", nil
|
|
}
|
|
path := runtime.Str("set-template-content-file")
|
|
if path == "" {
|
|
return "", "", nil
|
|
}
|
|
f, err := runtime.FileIO().Open(path)
|
|
if err != nil {
|
|
return "", path, output.ErrValidation("open --set-template-content-file %s: %v", path, err)
|
|
}
|
|
defer f.Close()
|
|
buf, err := io.ReadAll(f)
|
|
if err != nil {
|
|
return "", path, output.ErrValidation("read --set-template-content-file %s: %v", path, err)
|
|
}
|
|
return string(buf), path, nil
|
|
}
|
|
|
|
// templatePatchFile mirrors the --print-patch-template skeleton and the
|
|
// --patch-file JSON. Any field set to a non-nil value overrides the fetched
|
|
// template's corresponding field.
|
|
type templatePatchFile struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Subject *string `json:"subject,omitempty"`
|
|
TemplateContent *string `json:"template_content,omitempty"`
|
|
IsPlainTextMode *bool `json:"is_plain_text_mode,omitempty"`
|
|
Tos *[]templateMailAddr `json:"tos,omitempty"`
|
|
Ccs *[]templateMailAddr `json:"ccs,omitempty"`
|
|
Bccs *[]templateMailAddr `json:"bccs,omitempty"`
|
|
}
|
|
|
|
func applyTemplatePatchFile(tpl *templatePayload, patch *templatePatchFile) {
|
|
if patch == nil {
|
|
return
|
|
}
|
|
if patch.Name != nil {
|
|
tpl.Name = *patch.Name
|
|
}
|
|
if patch.Subject != nil {
|
|
tpl.Subject = *patch.Subject
|
|
}
|
|
if patch.TemplateContent != nil {
|
|
tpl.TemplateContent = *patch.TemplateContent
|
|
}
|
|
if patch.IsPlainTextMode != nil {
|
|
tpl.IsPlainTextMode = *patch.IsPlainTextMode
|
|
}
|
|
if patch.Tos != nil {
|
|
tpl.Tos = *patch.Tos
|
|
}
|
|
if patch.Ccs != nil {
|
|
tpl.Ccs = *patch.Ccs
|
|
}
|
|
if patch.Bccs != nil {
|
|
tpl.Bccs = *patch.Bccs
|
|
}
|
|
}
|
|
|
|
// buildTemplatePatchSkeleton returns the JSON skeleton printed by
|
|
// --print-patch-template to guide agents assembling a --patch-file.
|
|
func buildTemplatePatchSkeleton() map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"name": "string (≤100 chars, optional)",
|
|
"subject": "string (optional)",
|
|
"template_content": "string (HTML or plain text; local <img src> paths are auto-uploaded)",
|
|
"is_plain_text_mode": "bool (optional)",
|
|
"tos": []map[string]string{{"mail_address": "string", "name": "string(optional)"}},
|
|
"ccs": []map[string]string{{"mail_address": "string", "name": "string(optional)"}},
|
|
"bccs": []map[string]string{{"mail_address": "string", "name": "string(optional)"}},
|
|
}
|
|
}
|