diff --git a/shortcuts/mail/mail_template_create.go b/shortcuts/mail/mail_template_create.go new file mode 100644 index 000000000..1ec7524cc --- /dev/null +++ b/shortcuts/mail/mail_template_create.go @@ -0,0 +1,126 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// MailTemplateCreate creates a new email template. +var MailTemplateCreate = common.Shortcut{ + Service: "mail", + Command: "+template-create", + Description: "Create a new email template. The --body value is written to the request body as body_html (auto-detected HTML or plain text). Use --plain-text to force plain-text mode.", + Risk: "write", + Scopes: []string{"mail:user_mailbox.message:modify"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "name", Desc: "Required. Template name (max 100 chars)", Required: true}, + {Name: "subject", Desc: "Email subject for the template"}, + {Name: "body", Desc: "Email body (HTML or plain text). The value is written to body_html in the request body."}, + {Name: "to", Desc: "Default To recipients, comma-separated"}, + {Name: "cc", Desc: "Default CC recipients, comma-separated"}, + {Name: "bcc", Desc: "Default BCC recipients, comma-separated"}, + {Name: "plain-text", Type: "bool", Desc: "Force plain-text mode for body"}, + {Name: "mailbox", Default: "me", Desc: "Mailbox ID or email address (default: me)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + name := strings.TrimSpace(runtime.Str("name")) + if name == "" { + return output.ErrValidation("--name is required") + } + if len([]rune(name)) > 100 { + return output.ErrValidation("--name exceeds 100 character limit") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + mailboxID := resolveMailboxID(runtime) + return common.NewDryRunAPI(). + Desc("Create a new email template"). + POST(mailboxPath(mailboxID, "templates")). + Body(map[string]interface{}{"template": buildTemplateCreateBody(runtime)}) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + mailboxID := resolveMailboxID(runtime) + // Wrap the template object per IDL api.body="template" annotation so + // that apigw unwraps it into CreateUserMailboxTemplateRequest.Template. + body := map[string]interface{}{"template": buildTemplateCreateBody(runtime)} + data, err := runtime.CallAPI("POST", mailboxPath(mailboxID, "templates"), nil, body) + if err != nil { + return output.Errorf(output.ExitAPI, "api_error", "create template failed: %s", err) + } + tmpl := extractTemplateObject(data) + if tmpl == nil { + return output.Errorf(output.ExitAPI, "api_error", "create template: missing template in response") + } + runtime.OutFormat(tmpl, nil, func(w io.Writer) { + fmt.Fprintln(w, "Template created.") + fmt.Fprintf(w, "template_id: %s\n", strVal(tmpl["template_id"])) + fmt.Fprintf(w, "name: %s\n", strVal(tmpl["name"])) + }) + return nil + }, +} + +// buildTemplateCreateBody assembles the JSON body for +template-create. +// The value of --body is always written to body_html so that the server +// receives the user-supplied content (see verification report TPL-CREATE-01). +func buildTemplateCreateBody(runtime *common.RuntimeContext) map[string]interface{} { + body := map[string]interface{}{ + "name": runtime.Str("name"), + } + if s := runtime.Str("subject"); s != "" { + body["subject"] = s + } + // --body is the user's email body. It must be written to the + // request body's body_html field — the server uses body_html for both + // HTML and plain-text bodies, with is_plain_text_mode toggling the + // rendering mode. + if b := runtime.Str("body"); b != "" { + body["body_html"] = b + } + if runtime.Bool("plain-text") { + body["is_plain_text_mode"] = true + } + // Address list JSON keys match the IDL Template struct's api.json + // annotations (tos/ccs/bccs), while the --to/--cc/--bcc CLI flag names + // stay singular for a stable user surface. + if to := runtime.Str("to"); to != "" { + body["tos"] = parseAddressListForAPI(to) + } + if cc := runtime.Str("cc"); cc != "" { + body["ccs"] = parseAddressListForAPI(cc) + } + if bcc := runtime.Str("bcc"); bcc != "" { + body["bccs"] = parseAddressListForAPI(bcc) + } + return body +} + +// parseAddressListForAPI converts a comma-separated recipient string into the +// open-api MailAddress array shape: [{"mail_address": "...", "name": "..."}]. +// Entries with an empty email are dropped; empty input returns nil. +func parseAddressListForAPI(raw string) []map[string]interface{} { + boxes := ParseMailboxList(raw) + if len(boxes) == 0 { + return nil + } + out := make([]map[string]interface{}, 0, len(boxes)) + for _, m := range boxes { + entry := map[string]interface{}{"mail_address": m.Email} + if m.Name != "" { + entry["name"] = m.Name + } + out = append(out, entry) + } + return out +} diff --git a/shortcuts/mail/mail_template_delete.go b/shortcuts/mail/mail_template_delete.go new file mode 100644 index 000000000..dbf1e49ee --- /dev/null +++ b/shortcuts/mail/mail_template_delete.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// MailTemplateDelete deletes an email template. +var MailTemplateDelete = common.Shortcut{ + Service: "mail", + Command: "+template-delete", + Description: "Delete an email template by ID.", + Risk: "delete", + Scopes: []string{"mail:user_mailbox.message:modify"}, + AuthTypes: []string{"user"}, + Flags: []common.Flag{ + {Name: "template-id", Desc: "Required. Template ID to delete", Required: true}, + {Name: "mailbox", Default: "me", Desc: "Mailbox ID or email address (default: me)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("template-id")) == "" { + return output.ErrValidation("--template-id is required") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + mailboxID := resolveMailboxID(runtime) + templateID := runtime.Str("template-id") + return common.NewDryRunAPI(). + Desc("Delete an email template"). + DELETE(mailboxPath(mailboxID, "templates", templateID)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + mailboxID := resolveMailboxID(runtime) + templateID := runtime.Str("template-id") + if _, err := runtime.CallAPI("DELETE", mailboxPath(mailboxID, "templates", templateID), nil, nil); err != nil { + return output.Errorf(output.ExitAPI, "api_error", "delete template failed: %s", err) + } + runtime.Out(map[string]interface{}{ + "deleted": true, + "template_id": templateID, + }, nil) + return nil + }, +} diff --git a/shortcuts/mail/mail_template_get.go b/shortcuts/mail/mail_template_get.go new file mode 100644 index 000000000..e7cb8e3da --- /dev/null +++ b/shortcuts/mail/mail_template_get.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// MailTemplateGet fetches a single email template by ID. +var MailTemplateGet = common.Shortcut{ + Service: "mail", + Command: "+template-get", + Description: "Get a specific email template by ID, including subject, body and default recipients.", + Risk: "read", + Scopes: []string{"mail:user_mailbox.message:readonly"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "template-id", Desc: "Required. Template ID", Required: true}, + {Name: "mailbox", Default: "me", Desc: "Mailbox ID or email address (default: me)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("template-id")) == "" { + return output.ErrValidation("--template-id is required") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + mailboxID := resolveMailboxID(runtime) + templateID := runtime.Str("template-id") + return common.NewDryRunAPI(). + Desc("Get email template details"). + GET(mailboxPath(mailboxID, "templates", templateID)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + mailboxID := resolveMailboxID(runtime) + templateID := runtime.Str("template-id") + data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "templates", templateID), nil, nil) + if err != nil { + return output.Errorf(output.ExitAPI, "api_error", "get template failed: %s", err) + } + tmpl := extractTemplateObject(data) + if tmpl == nil { + return output.Errorf(output.ExitAPI, "api_error", "template %s not found", templateID) + } + runtime.OutFormat(tmpl, nil, func(w io.Writer) { + fmt.Fprintf(w, "Template: %s\n", strVal(tmpl["name"])) + fmt.Fprintf(w, "Subject: %s\n", strVal(tmpl["subject"])) + fmt.Fprintf(w, "ID: %s\n", strVal(tmpl["template_id"])) + if to := tmpl["tos"]; to != nil { + fmt.Fprintf(w, "To: %s\n", describeAddressField(to)) + } + if cc := tmpl["ccs"]; cc != nil { + fmt.Fprintf(w, "Cc: %s\n", describeAddressField(cc)) + } + if bcc := tmpl["bccs"]; bcc != nil { + fmt.Fprintf(w, "Bcc: %s\n", describeAddressField(bcc)) + } + }) + return nil + }, +} + +// extractTemplateObject returns the "template" field from an API response, +// tolerating responses that already flattened the object at the top level. +func extractTemplateObject(data map[string]interface{}) map[string]interface{} { + if data == nil { + return nil + } + if tmpl, ok := data["template"].(map[string]interface{}); ok { + return tmpl + } + if _, hasID := data["template_id"]; hasID { + return data + } + return nil +} + +// describeAddressField renders a template address list for pretty output. +// It accepts []interface{} of {mail_address,name} maps or a plain string. +func describeAddressField(v interface{}) string { + switch t := v.(type) { + case string: + return t + case []interface{}: + parts := make([]string, 0, len(t)) + for _, item := range t { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + email := strVal(m["mail_address"]) + if email == "" { + email = strVal(m["email"]) + } + name := strVal(m["name"]) + if name != "" { + parts = append(parts, fmt.Sprintf("%s <%s>", name, email)) + } else if email != "" { + parts = append(parts, email) + } + } + return strings.Join(parts, ", ") + } + return "" +} diff --git a/shortcuts/mail/mail_template_list.go b/shortcuts/mail/mail_template_list.go new file mode 100644 index 000000000..091a294b4 --- /dev/null +++ b/shortcuts/mail/mail_template_list.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// MailTemplateList lists all email templates in the specified mailbox. +var MailTemplateList = common.Shortcut{ + Service: "mail", + Command: "+template-list", + Description: "List all email templates in the current mailbox. Returns template_id, name, subject and create_time for each template.", + Risk: "read", + Scopes: []string{"mail:user_mailbox.message:readonly"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "mailbox", Default: "me", Desc: "Mailbox ID or email address (default: me)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + mailboxID := resolveMailboxID(runtime) + return common.NewDryRunAPI(). + Desc("List all email templates for the specified mailbox"). + GET(mailboxPath(mailboxID, "templates")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + mailboxID := resolveMailboxID(runtime) + data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "templates"), nil, nil) + if err != nil { + return output.Errorf(output.ExitAPI, "api_error", "list templates failed: %s", err) + } + items := extractTemplateList(data) + runtime.OutFormat(map[string]interface{}{"items": items}, &output.Meta{Count: len(items)}, + func(w io.Writer) { + if len(items) == 0 { + fmt.Fprintln(w, "No templates found.") + return + } + rows := make([]map[string]interface{}, 0, len(items)) + for _, t := range items { + tm, _ := t.(map[string]interface{}) + if tm == nil { + continue + } + rows = append(rows, map[string]interface{}{ + "template_id": tm["template_id"], + "name": tm["name"], + "subject": common.TruncateStr(fmt.Sprint(tm["subject"]), 40), + "create_time": tm["create_time"], + }) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%d template(s)\n", len(items)) + }) + return nil + }, +} + +// extractTemplateList returns the list of templates from an open-apis list response. +// The API uses the "items" field (see tech-design ListUserMailboxTemplateResponse); +// older call sites used "templates", so both are accepted for forward/backward compat. +func extractTemplateList(data map[string]interface{}) []interface{} { + if data == nil { + return nil + } + if list, ok := data["items"].([]interface{}); ok { + return list + } + if list, ok := data["templates"].([]interface{}); ok { + return list + } + return nil +} diff --git a/shortcuts/mail/mail_template_send.go b/shortcuts/mail/mail_template_send.go new file mode 100644 index 000000000..0f144098d --- /dev/null +++ b/shortcuts/mail/mail_template_send.go @@ -0,0 +1,229 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "fmt" + "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/emlbuilder" +) + +// MailTemplateSend composes an email from a template and saves it as a draft +// (or sends immediately with --confirm-send). +var MailTemplateSend = common.Shortcut{ + Service: "mail", + Command: "+template-send", + Description: "Compose an email from a template and save as draft (default). " + + "Use --confirm-send to send the draft immediately after user confirmation.", + Risk: "write", + Scopes: []string{ + "mail:user_mailbox.message:readonly", + "mail:user_mailbox.message:send", + "mail:user_mailbox.message:modify", + "mail:user_mailbox:readonly", + }, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "template-id", Desc: "Required. Template ID to use", Required: true}, + {Name: "to", Desc: "Override To recipients (comma-separated). If omitted, uses template defaults."}, + {Name: "subject", Desc: "Override subject. If omitted, uses template subject."}, + {Name: "body", Desc: "Override body. If omitted, uses template body."}, + {Name: "cc", Desc: "Override CC recipients"}, + {Name: "bcc", Desc: "Override BCC recipients"}, + {Name: "from", Desc: "Sender address (defaults to the authenticated user's primary mailbox)"}, + {Name: "confirm-send", Type: "bool", Desc: "Send the email immediately instead of saving as draft."}, + {Name: "mailbox", Default: "me", Desc: "Mailbox ID (default: me)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("template-id")) == "" { + return output.ErrValidation("--template-id is required") + } + return validateConfirmSendScope(runtime) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + mailboxID := resolveMailboxID(runtime) + templateID := runtime.Str("template-id") + confirmSend := runtime.Bool("confirm-send") + desc := "Load template → build EML → create draft" + if confirmSend { + desc += " → send draft" + } + api := common.NewDryRunAPI(). + Desc(desc). + GET(mailboxPath(mailboxID, "templates", templateID)). + GET(mailboxPath(mailboxID, "profile")). + POST(mailboxPath(mailboxID, "drafts")) + if confirmSend { + api = api.POST(mailboxPath(mailboxID, "drafts", "", "send")) + } + return api + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + mailboxID := resolveMailboxID(runtime) + templateID := runtime.Str("template-id") + confirmSend := runtime.Bool("confirm-send") + + // 1. Fetch the template. + tmplResp, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "templates", templateID), nil, nil) + if err != nil { + return output.Errorf(output.ExitAPI, "api_error", "get template failed: %s", err) + } + tmpl := extractTemplateObject(tmplResp) + if tmpl == nil { + return output.Errorf(output.ExitAPI, "api_error", "template %s not found", templateID) + } + + // 2. Merge flags over the template defaults. + merged, err := mergeTemplateSendFields(runtime, tmpl) + if err != nil { + return err + } + + // 3. Resolve sender. + senderEmail := strings.TrimSpace(runtime.Str("from")) + if senderEmail == "" { + if email, fetchErr := fetchMailboxPrimaryEmail(runtime, "me"); fetchErr == nil { + senderEmail = email + } + } + + // 4. Build EML. + rawEML, err := buildTemplateEML(merged, senderEmail) + if err != nil { + return output.Errorf(output.ExitAPI, "build_error", "build EML from template failed: %s", err) + } + + // 5. Create draft. + draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML) + if err != nil { + return fmt.Errorf("create draft from template failed: %w", err) + } + + if !confirmSend { + runtime.Out(map[string]interface{}{ + "draft_id": draftID, + "template_id": templateID, + "tip": fmt.Sprintf( + `draft saved from template. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, + mailboxID, draftID), + }, nil) + hintSendDraft(runtime, mailboxID, draftID) + return nil + } + + // 6. Send immediately. + resData, err := draftpkg.Send(runtime, mailboxID, draftID) + if err != nil { + return fmt.Errorf("draft %s created from template but send failed: %w", draftID, err) + } + runtime.Out(map[string]interface{}{ + "message_id": resData["message_id"], + "thread_id": resData["thread_id"], + "template_id": templateID, + }, nil) + return nil + }, +} + +// templateSendFields holds the merged fields used to construct the outgoing EML. +type templateSendFields struct { + Subject string + Body string + To string + CC string + BCC string + PlainText bool +} + +// mergeTemplateSendFields overlays user flags on top of the template defaults +// and validates the recipient requirement. +func mergeTemplateSendFields(runtime *common.RuntimeContext, tmpl map[string]interface{}) (templateSendFields, error) { + merged := templateSendFields{ + Subject: coalesceStr(runtime.Str("subject"), strVal(tmpl["subject"])), + Body: coalesceStr(runtime.Str("body"), strVal(tmpl["body_html"])), + To: coalesceStr(runtime.Str("to"), stringifyTemplateAddressList(tmpl["to"])), + CC: coalesceStr(runtime.Str("cc"), stringifyTemplateAddressList(tmpl["cc"])), + BCC: coalesceStr(runtime.Str("bcc"), stringifyTemplateAddressList(tmpl["bcc"])), + PlainText: boolVal(tmpl["is_plain_text_mode"]), + } + if strings.TrimSpace(merged.To) == "" { + return merged, output.ErrValidation( + "no recipients: template has no default To and --to was not specified") + } + return merged, nil +} + +// buildTemplateEML constructs a base64url-encoded EML from the merged template fields. +func buildTemplateEML(merged templateSendFields, senderEmail string) (string, error) { + bld := emlbuilder.New(). + Subject(merged.Subject). + ToAddrs(parseNetAddrs(merged.To)) + if senderEmail != "" { + bld = bld.From("", senderEmail) + } + if merged.CC != "" { + bld = bld.CCAddrs(parseNetAddrs(merged.CC)) + } + if merged.BCC != "" { + bld = bld.BCCAddrs(parseNetAddrs(merged.BCC)) + } + switch { + case merged.PlainText: + bld = bld.TextBody([]byte(merged.Body)) + case bodyIsHTML(merged.Body): + bld = bld.HTMLBody([]byte(merged.Body)) + default: + bld = bld.TextBody([]byte(merged.Body)) + } + return bld.BuildBase64URL() +} + +// coalesceStr returns the first non-empty (after trimming) value, or "" if none. +func coalesceStr(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +// stringifyTemplateAddressList renders an API-shaped address list back to the +// comma-separated "Name , email2" form used by --to/--cc/--bcc. +func stringifyTemplateAddressList(v interface{}) string { + list, ok := v.([]interface{}) + if !ok { + if s, ok := v.(string); ok { + return s + } + return "" + } + parts := make([]string, 0, len(list)) + for _, item := range list { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + email := strVal(m["mail_address"]) + if email == "" { + email = strVal(m["email"]) + } + if email == "" { + continue + } + name := strVal(m["name"]) + if name != "" { + parts = append(parts, fmt.Sprintf("%s <%s>", name, email)) + } else { + parts = append(parts, email) + } + } + return strings.Join(parts, ", ") +} diff --git a/shortcuts/mail/mail_template_test.go b/shortcuts/mail/mail_template_test.go new file mode 100644 index 000000000..7fc806c85 --- /dev/null +++ b/shortcuts/mail/mail_template_test.go @@ -0,0 +1,875 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// runtimeForMailTemplateTest builds a minimal RuntimeContext with the declared +// flags of the given shortcut. Values is a map of flag name → value; bool flags +// accept "true"/"false" as strings. +func runtimeForMailTemplateTest(t *testing.T, sc common.Shortcut, values map[string]string) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "test"} + for _, fl := range sc.Flags { + switch fl.Type { + case "bool": + cmd.Flags().Bool(fl.Name, fl.Default == "true", "") + case "int": + cmd.Flags().Int(fl.Name, 0, "") + default: + cmd.Flags().String(fl.Name, fl.Default, "") + } + } + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("parse flags failed: %v", err) + } + for k, v := range values { + if err := cmd.Flags().Set(k, v); err != nil { + t.Fatalf("set --%s=%s failed: %v", k, v, err) + } + } + return &common.RuntimeContext{Cmd: cmd} +} + +// --- helper-level tests ----------------------------------------------------- + +func TestParseAddressListForAPI_ReturnsNilOnEmpty(t *testing.T) { + if got := parseAddressListForAPI(""); got != nil { + t.Fatalf("expected nil for empty input, got %#v", got) + } + if got := parseAddressListForAPI(" "); got != nil { + t.Fatalf("expected nil for whitespace-only, got %#v", got) + } +} + +func TestParseAddressListForAPI_WithNamesAndPlainAddresses(t *testing.T) { + got := parseAddressListForAPI(`Alice , bob@example.com`) + if len(got) != 2 { + t.Fatalf("expected 2 entries, got %#v", got) + } + if got[0]["mail_address"] != "alice@example.com" { + t.Fatalf("entry[0].mail_address = %#v", got[0]["mail_address"]) + } + if got[0]["name"] != "Alice" { + t.Fatalf("entry[0].name = %#v", got[0]["name"]) + } + if got[1]["mail_address"] != "bob@example.com" { + t.Fatalf("entry[1].mail_address = %#v", got[1]["mail_address"]) + } + if _, hasName := got[1]["name"]; hasName { + t.Fatalf("entry[1] unexpected name: %#v", got[1]) + } +} + +func TestExtractTemplateList_PrefersItems(t *testing.T) { + data := map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"template_id": "t1"}, + }, + } + got := extractTemplateList(data) + if len(got) != 1 { + t.Fatalf("expected 1 item, got %#v", got) + } +} + +func TestExtractTemplateList_FallsBackToTemplatesKey(t *testing.T) { + data := map[string]interface{}{ + "templates": []interface{}{map[string]interface{}{"template_id": "t1"}}, + } + if got := extractTemplateList(data); len(got) != 1 { + t.Fatalf("expected fallback to templates, got %#v", got) + } +} + +func TestExtractTemplateList_NilData(t *testing.T) { + if got := extractTemplateList(nil); got != nil { + t.Fatalf("expected nil, got %#v", got) + } +} + +func TestExtractTemplateObject_NestedAndFlat(t *testing.T) { + nested := map[string]interface{}{ + "template": map[string]interface{}{"template_id": "t1", "name": "n"}, + } + if got := extractTemplateObject(nested); got == nil || got["template_id"] != "t1" { + t.Fatalf("nested extract failed: %#v", got) + } + + flat := map[string]interface{}{"template_id": "t2", "name": "flat"} + if got := extractTemplateObject(flat); got == nil || got["template_id"] != "t2" { + t.Fatalf("flat extract failed: %#v", got) + } + + if got := extractTemplateObject(map[string]interface{}{"other": "x"}); got != nil { + t.Fatalf("expected nil for unknown shape, got %#v", got) + } +} + +func TestDescribeAddressField(t *testing.T) { + in := []interface{}{ + map[string]interface{}{"mail_address": "alice@example.com", "name": "Alice"}, + map[string]interface{}{"mail_address": "bob@example.com"}, + } + got := describeAddressField(in) + if got != "Alice , bob@example.com" { + t.Fatalf("unexpected describe: %q", got) + } + if describeAddressField("alice@example.com") != "alice@example.com" { + t.Fatal("plain string passthrough failed") + } + if describeAddressField(nil) != "" { + t.Fatal("nil should render empty") + } +} + +func TestStringifyTemplateAddressList(t *testing.T) { + list := []interface{}{ + map[string]interface{}{"mail_address": "a@example.com", "name": "A"}, + map[string]interface{}{"mail_address": "b@example.com"}, + } + if got := stringifyTemplateAddressList(list); got != "A , b@example.com" { + t.Fatalf("unexpected output: %q", got) + } + if got := stringifyTemplateAddressList(nil); got != "" { + t.Fatalf("expected empty, got %q", got) + } + if got := stringifyTemplateAddressList("passthrough@example.com"); got != "passthrough@example.com" { + t.Fatalf("expected passthrough string, got %q", got) + } +} + +func TestCoalesceStr(t *testing.T) { + if got := coalesceStr("", " ", "first"); got != "first" { + t.Fatalf("expected 'first', got %q", got) + } + if got := coalesceStr("a", "b"); got != "a" { + t.Fatalf("expected 'a', got %q", got) + } + if got := coalesceStr("", " "); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + +// --- buildTemplateCreateBody: TPL-CREATE-01 regression --------------------- + +// TestBuildTemplateCreateBody_BodyFlagWritesToBodyHtml is the direct regression +// test for the verification report item TPL-CREATE-01: the --body flag MUST be +// written to the inner template object's body_html field. +func TestBuildTemplateCreateBody_BodyFlagWritesToBodyHtml(t *testing.T) { + runtime := runtimeForMailTemplateTest(t, MailTemplateCreate, map[string]string{ + "name": "regression", + "subject": "hello", + "body": "

Hi Alice

", + }) + body := buildTemplateCreateBody(runtime) + got, ok := body["body_html"].(string) + if !ok { + t.Fatalf("body_html missing or not string in request body: %#v", body) + } + if got != "

Hi Alice

" { + t.Fatalf("body_html = %q, want the exact --body value", got) + } + if body["name"] != "regression" { + t.Fatalf("name mismatch: %#v", body["name"]) + } + if body["subject"] != "hello" { + t.Fatalf("subject mismatch: %#v", body["subject"]) + } + if _, hasPlain := body["is_plain_text_mode"]; hasPlain { + t.Fatalf("is_plain_text_mode should be absent when --plain-text is not set") + } +} + +func TestBuildTemplateCreateBody_OmitsEmptyFields(t *testing.T) { + runtime := runtimeForMailTemplateTest(t, MailTemplateCreate, map[string]string{ + "name": "only-name", + }) + body := buildTemplateCreateBody(runtime) + if body["name"] != "only-name" { + t.Fatalf("name mismatch: %#v", body) + } + for _, key := range []string{"subject", "body_html", "tos", "ccs", "bccs", "is_plain_text_mode"} { + if _, ok := body[key]; ok { + t.Fatalf("unexpected %q in minimal body: %#v", key, body) + } + } +} + +func TestBuildTemplateCreateBody_IncludesRecipientsAndPlainText(t *testing.T) { + runtime := runtimeForMailTemplateTest(t, MailTemplateCreate, map[string]string{ + "name": "full", + "to": "alice@example.com", + "cc": "Bob ", + "bcc": "carol@example.com", + "plain-text": "true", + "body": "plaintext content", + }) + body := buildTemplateCreateBody(runtime) + if body["is_plain_text_mode"] != true { + t.Fatalf("is_plain_text_mode = %#v, want true", body["is_plain_text_mode"]) + } + if body["body_html"] != "plaintext content" { + t.Fatalf("body_html should be the --body value even with --plain-text; got %#v", body["body_html"]) + } + tos, ok := body["tos"].([]map[string]interface{}) + if !ok || len(tos) != 1 || tos[0]["mail_address"] != "alice@example.com" { + t.Fatalf("tos mismatch: %#v", body["tos"]) + } + ccs, ok := body["ccs"].([]map[string]interface{}) + if !ok || len(ccs) != 1 || ccs[0]["mail_address"] != "bob@example.com" || ccs[0]["name"] != "Bob" { + t.Fatalf("ccs mismatch: %#v", body["ccs"]) + } + bccs, ok := body["bccs"].([]map[string]interface{}) + if !ok || len(bccs) != 1 || bccs[0]["mail_address"] != "carol@example.com" { + t.Fatalf("bccs mismatch: %#v", body["bccs"]) + } +} + +// --- shortcut metadata sanity checks --------------------------------------- + +func TestMailTemplateShortcutsMetadata(t *testing.T) { + cases := []struct { + s common.Shortcut + cmd string + risk string + mustScope []string + }{ + {MailTemplateList, "+template-list", "read", []string{"mail:user_mailbox.message:readonly"}}, + {MailTemplateGet, "+template-get", "read", []string{"mail:user_mailbox.message:readonly"}}, + {MailTemplateCreate, "+template-create", "write", []string{"mail:user_mailbox.message:modify"}}, + {MailTemplateUpdate, "+template-update", "write", []string{"mail:user_mailbox.message:modify"}}, + {MailTemplateDelete, "+template-delete", "delete", []string{"mail:user_mailbox.message:modify"}}, + {MailTemplateSend, "+template-send", "write", []string{ + "mail:user_mailbox.message:readonly", + "mail:user_mailbox.message:send", + "mail:user_mailbox.message:modify", + }}, + } + for _, tc := range cases { + if tc.s.Command != tc.cmd { + t.Errorf("%s: Command = %q", tc.cmd, tc.s.Command) + } + if tc.s.Risk != tc.risk { + t.Errorf("%s: Risk = %q, want %q", tc.cmd, tc.s.Risk, tc.risk) + } + if len(tc.s.AuthTypes) == 0 || tc.s.AuthTypes[0] != "user" { + t.Errorf("%s: expected AuthTypes contains 'user', got %#v", tc.cmd, tc.s.AuthTypes) + } + for _, must := range tc.mustScope { + if !containsString(tc.s.Scopes, must) { + t.Errorf("%s: missing scope %q (got %#v)", tc.cmd, must, tc.s.Scopes) + } + } + } +} + +func TestMailTemplateShortcutsRegistered(t *testing.T) { + set := make(map[string]bool) + for _, sc := range Shortcuts() { + set[sc.Command] = true + } + for _, want := range []string{ + "+template-list", "+template-get", "+template-create", + "+template-update", "+template-delete", "+template-send", + } { + if !set[want] { + t.Errorf("Shortcuts() missing %q (got %v)", want, set) + } + } +} + +func containsString(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + +// --- validation tests ------------------------------------------------------ + +func TestMailTemplateCreateValidate_NameRequired(t *testing.T) { + runtime := runtimeForMailTemplateTest(t, MailTemplateCreate, map[string]string{}) + err := MailTemplateCreate.Validate(context.Background(), runtime) + if err == nil { + t.Fatal("expected validation error when --name is empty") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Code != output.ExitValidation { + t.Fatalf("expected ExitValidation, got %T: %v", err, err) + } +} + +func TestMailTemplateCreateValidate_NameTooLong(t *testing.T) { + long := strings.Repeat("x", 101) + runtime := runtimeForMailTemplateTest(t, MailTemplateCreate, map[string]string{"name": long}) + err := MailTemplateCreate.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "100") { + t.Fatalf("expected 100-char limit error, got %v", err) + } +} + +func TestMailTemplateCreateValidate_UnicodeNameUnder100(t *testing.T) { + // 100 runes of a 4-byte emoji fits the rune budget exactly. + name := strings.Repeat("模", 100) + runtime := runtimeForMailTemplateTest(t, MailTemplateCreate, map[string]string{"name": name}) + if err := MailTemplateCreate.Validate(context.Background(), runtime); err != nil { + t.Fatalf("expected no error for 100-rune name, got %v", err) + } +} + +func TestMailTemplateGetValidate_RequiresTemplateID(t *testing.T) { + runtime := runtimeForMailTemplateTest(t, MailTemplateGet, map[string]string{}) + if err := MailTemplateGet.Validate(context.Background(), runtime); err == nil { + t.Fatal("expected validation error") + } +} + +func TestMailTemplateUpdateValidate_RequiresTemplateID(t *testing.T) { + runtime := runtimeForMailTemplateTest(t, MailTemplateUpdate, map[string]string{}) + if err := MailTemplateUpdate.Validate(context.Background(), runtime); err == nil { + t.Fatal("expected validation error") + } +} + +func TestMailTemplateDeleteValidate_RequiresTemplateID(t *testing.T) { + runtime := runtimeForMailTemplateTest(t, MailTemplateDelete, map[string]string{}) + if err := MailTemplateDelete.Validate(context.Background(), runtime); err == nil { + t.Fatal("expected validation error") + } +} + +func TestMailTemplateSendValidate_RequiresTemplateID(t *testing.T) { + runtime := runtimeForMailTemplateTest(t, MailTemplateSend, map[string]string{}) + if err := MailTemplateSend.Validate(context.Background(), runtime); err == nil { + t.Fatal("expected validation error") + } +} + +// --- dry-run tests --------------------------------------------------------- + +func TestMailTemplateListDryRun(t *testing.T) { + runtime := runtimeForMailTemplateTest(t, MailTemplateList, map[string]string{}) + dry := MailTemplateList.DryRun(context.Background(), runtime) + calls := dryRunAPIsForMailTemplateTest(t, dry) + if len(calls) != 1 || calls[0].Method != "GET" { + t.Fatalf("unexpected dry-run calls: %#v", calls) + } + if !strings.HasSuffix(calls[0].URL, "/user_mailboxes/me/templates") { + t.Fatalf("unexpected URL: %s", calls[0].URL) + } +} + +func TestMailTemplateGetDryRun(t *testing.T) { + runtime := runtimeForMailTemplateTest(t, MailTemplateGet, map[string]string{"template-id": "tpl_1"}) + dry := MailTemplateGet.DryRun(context.Background(), runtime) + calls := dryRunAPIsForMailTemplateTest(t, dry) + if len(calls) != 1 || calls[0].Method != "GET" { + t.Fatalf("unexpected dry-run calls: %#v", calls) + } + if !strings.HasSuffix(calls[0].URL, "/templates/tpl_1") { + t.Fatalf("unexpected URL: %s", calls[0].URL) + } +} + +func TestMailTemplateCreateDryRun(t *testing.T) { + runtime := runtimeForMailTemplateTest(t, MailTemplateCreate, map[string]string{ + "name": "n", + "subject": "s", + "body": "

b

", + "to": "alice@example.com", + }) + dry := MailTemplateCreate.DryRun(context.Background(), runtime) + calls := dryRunAPIsForMailTemplateTest(t, dry) + if len(calls) != 1 || calls[0].Method != "POST" { + t.Fatalf("unexpected dry-run calls: %#v", calls) + } + // The HTTP body must wrap the template object per IDL api.body="template": + // {"template": {"name": "...", "body_html": "...", "tos": [...]}} + wrap, _ := calls[0].Body.(map[string]interface{}) + if wrap == nil { + t.Fatalf("expected body in dry-run, got %#v", calls[0].Body) + } + inner, ok := wrap["template"].(map[string]interface{}) + if !ok { + t.Fatalf("dry-run body should be wrapped under \"template\"; got %#v", wrap) + } + if inner["body_html"] != "

b

" { + t.Fatalf("body_html not in wrapped template: %#v", inner) + } + if inner["name"] != "n" || inner["subject"] != "s" { + t.Fatalf("inner name/subject mismatch: %#v", inner) + } + // After json round-trip the array element type is []interface{} of map[string]interface{}. + tos, ok := inner["tos"].([]interface{}) + if !ok || len(tos) != 1 { + t.Fatalf("inner tos mismatch: %#v", inner["tos"]) + } + firstTo, _ := tos[0].(map[string]interface{}) + if firstTo["mail_address"] != "alice@example.com" { + t.Fatalf("inner tos[0].mail_address = %#v", firstTo["mail_address"]) + } + if _, flat := wrap["body_html"]; flat { + t.Fatalf("dry-run body must not be flat; got body_html at top level: %#v", wrap) + } +} + +func TestMailTemplateUpdateDryRun(t *testing.T) { + runtime := runtimeForMailTemplateTest(t, MailTemplateUpdate, map[string]string{ + "template-id": "tpl_1", + }) + dry := MailTemplateUpdate.DryRun(context.Background(), runtime) + calls := dryRunAPIsForMailTemplateTest(t, dry) + if len(calls) != 2 { + t.Fatalf("expected 2 dry-run calls (GET then PUT), got %#v", calls) + } + if calls[0].Method != "GET" || calls[1].Method != "PUT" { + t.Fatalf("unexpected dry-run sequence: %#v", calls) + } +} + +func TestMailTemplateDeleteDryRun(t *testing.T) { + runtime := runtimeForMailTemplateTest(t, MailTemplateDelete, map[string]string{"template-id": "tpl_1"}) + dry := MailTemplateDelete.DryRun(context.Background(), runtime) + calls := dryRunAPIsForMailTemplateTest(t, dry) + if len(calls) != 1 || calls[0].Method != "DELETE" { + t.Fatalf("unexpected dry-run calls: %#v", calls) + } +} + +func TestMailTemplateSendDryRun_DraftOnly(t *testing.T) { + runtime := runtimeForMailTemplateTest(t, MailTemplateSend, map[string]string{"template-id": "tpl_1"}) + dry := MailTemplateSend.DryRun(context.Background(), runtime) + calls := dryRunAPIsForMailTemplateTest(t, dry) + if len(calls) != 3 { + t.Fatalf("expected 3 dry-run calls for draft-only send, got %#v", calls) + } + if calls[0].Method != "GET" || calls[2].Method != "POST" { + t.Fatalf("unexpected dry-run sequence: %#v", calls) + } +} + +func TestMailTemplateSendDryRun_ConfirmSend(t *testing.T) { + runtime := runtimeForMailTemplateTest(t, MailTemplateSend, map[string]string{ + "template-id": "tpl_1", + "confirm-send": "true", + }) + dry := MailTemplateSend.DryRun(context.Background(), runtime) + calls := dryRunAPIsForMailTemplateTest(t, dry) + if len(calls) != 4 { + t.Fatalf("expected 4 dry-run calls with --confirm-send, got %#v", calls) + } + if calls[3].Method != "POST" || !strings.Contains(calls[3].URL, "/send") { + t.Fatalf("last call should be POST .../send, got %#v", calls[3]) + } +} + +// dryRunAPIsForMailTemplateTest marshals a DryRunAPI into its nested API list. +func dryRunAPIsForMailTemplateTest(t *testing.T, dry *common.DryRunAPI) []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params,omitempty"` + Body interface{} `json:"body"` +} { + t.Helper() + b, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry-run: %v", err) + } + var payload struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params,omitempty"` + Body interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(b, &payload); err != nil { + t.Fatalf("unmarshal dry-run: %v", err) + } + return payload.API +} + +// --- mergeTemplateSendFields ---------------------------------------------- + +func TestMergeTemplateSendFields_UsesTemplateDefaultsWhenFlagsEmpty(t *testing.T) { + runtime := runtimeForMailTemplateTest(t, MailTemplateSend, map[string]string{ + "template-id": "tpl_1", + }) + tmpl := map[string]interface{}{ + "subject": "Tmpl subject", + "body_html": "

Tmpl body

", + "is_plain_text_mode": false, + "to": []interface{}{ + map[string]interface{}{"mail_address": "alice@example.com", "name": "Alice"}, + }, + "cc": []interface{}{ + map[string]interface{}{"mail_address": "cc@example.com"}, + }, + } + merged, err := mergeTemplateSendFields(runtime, tmpl) + if err != nil { + t.Fatalf("mergeTemplateSendFields: %v", err) + } + if merged.Subject != "Tmpl subject" { + t.Errorf("subject = %q", merged.Subject) + } + if merged.Body != "

Tmpl body

" { + t.Errorf("body = %q", merged.Body) + } + if merged.To != "Alice " { + t.Errorf("to = %q", merged.To) + } + if merged.CC != "cc@example.com" { + t.Errorf("cc = %q", merged.CC) + } + if merged.PlainText { + t.Errorf("plain-text should be false") + } +} + +func TestMergeTemplateSendFields_FlagsOverrideTemplate(t *testing.T) { + runtime := runtimeForMailTemplateTest(t, MailTemplateSend, map[string]string{ + "template-id": "tpl_1", + "subject": "Flag subject", + "body": "Flag body", + "to": "override@example.com", + }) + tmpl := map[string]interface{}{ + "subject": "Tmpl subject", + "body_html": "

Tmpl body

", + "to": []interface{}{ + map[string]interface{}{"mail_address": "alice@example.com"}, + }, + } + merged, err := mergeTemplateSendFields(runtime, tmpl) + if err != nil { + t.Fatalf("mergeTemplateSendFields: %v", err) + } + if merged.Subject != "Flag subject" || merged.Body != "Flag body" || merged.To != "override@example.com" { + t.Fatalf("flag override failed: %+v", merged) + } +} + +func TestMergeTemplateSendFields_ErrorsWhenNoRecipient(t *testing.T) { + runtime := runtimeForMailTemplateTest(t, MailTemplateSend, map[string]string{ + "template-id": "tpl_1", + }) + tmpl := map[string]interface{}{"subject": "s", "body_html": "b"} + _, err := mergeTemplateSendFields(runtime, tmpl) + if err == nil { + t.Fatal("expected error when template has no default To and --to is empty") + } + if !strings.Contains(err.Error(), "no recipients") { + t.Fatalf("expected 'no recipients' error, got: %v", err) + } +} + +// --- buildTemplateEML ------------------------------------------------------ + +func TestBuildTemplateEML_PlainTextBody(t *testing.T) { + merged := templateSendFields{ + Subject: "hi", + Body: "plain body", + To: "alice@example.com", + PlainText: true, + } + raw, err := buildTemplateEML(merged, "sender@example.com") + if err != nil { + t.Fatalf("buildTemplateEML: %v", err) + } + eml := decodeBase64URL(raw) + if !strings.Contains(eml, "alice@example.com") { + t.Fatal("missing To address in EML") + } + if !strings.Contains(eml, "Subject: hi") { + t.Fatal("missing Subject in EML") + } + if strings.Contains(eml, "Content-Type: text/html") { + t.Fatal("plain-text body should not emit text/html part") + } +} + +func TestBuildTemplateEML_HTMLBody(t *testing.T) { + merged := templateSendFields{ + Subject: "hi", + Body: "

hello

", + To: "alice@example.com", + } + raw, err := buildTemplateEML(merged, "sender@example.com") + if err != nil { + t.Fatalf("buildTemplateEML: %v", err) + } + eml := decodeBase64URL(raw) + if !strings.Contains(eml, "text/html") { + t.Fatal("expected HTML part in EML when body is HTML") + } +} + +// --- merge update flags ---------------------------------------------------- + +func TestMergeTemplateUpdateFlags_OverlaysNonEmptyFields(t *testing.T) { + runtime := runtimeForMailTemplateTest(t, MailTemplateUpdate, map[string]string{ + "template-id": "tpl_1", + "name": "new-name", + "body": "

new

", + "to": "x@example.com", + }) + tmpl := map[string]interface{}{ + "template_id": "tpl_1", + "name": "old", + "subject": "keep-subject", + "body_html": "

old

", + } + mergeTemplateUpdateFlags(runtime, tmpl) + if tmpl["name"] != "new-name" { + t.Errorf("name not overlayed: %#v", tmpl["name"]) + } + if tmpl["subject"] != "keep-subject" { + t.Errorf("subject should be preserved when flag is empty: %#v", tmpl["subject"]) + } + if tmpl["body_html"] != "

new

" { + t.Errorf("body_html not overlayed: %#v", tmpl["body_html"]) + } + tos, ok := tmpl["tos"].([]map[string]interface{}) + if !ok || len(tos) != 1 || tos[0]["mail_address"] != "x@example.com" { + t.Errorf("tos not overlayed: %#v", tmpl["tos"]) + } +} + +// --- end-to-end httpmock tests --------------------------------------------- + +func TestMailTemplateList_E2E(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/mail/v1/user_mailboxes/me/templates", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "template_id": "tpl_1", + "name": "周报模板", + "subject": "Weekly report", + "create_time": "1700000000000", + }, + }, + }, + }, + }) + err := runMountedMailShortcut(t, MailTemplateList, []string{"+template-list"}, f, stdout) + if err != nil { + t.Fatalf("runMountedMailShortcut: %v", err) + } + data := decodeShortcutEnvelopeData(t, stdout) + items, _ := data["items"].([]interface{}) + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d: %s", len(items), stdout.String()) + } + first := items[0].(map[string]interface{}) + if first["template_id"] != "tpl_1" { + t.Errorf("template_id mismatch: %#v", first["template_id"]) + } +} + +func TestMailTemplateCreate_E2E_WritesBodyHtml(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + + createStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/mail/v1/user_mailboxes/me/templates", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "template": map[string]interface{}{ + "template_id": "tpl_new", + "name": "My Tpl", + }, + }, + }, + } + reg.Register(createStub) + + err := runMountedMailShortcut(t, MailTemplateCreate, []string{ + "+template-create", + "--name", "My Tpl", + "--subject", "hello", + "--body", "

Body from CLI

", + "--to", "alice@example.com", + }, f, stdout) + if err != nil { + t.Fatalf("runMountedMailShortcut: %v", err) + } + + var wrap map[string]interface{} + if err := json.Unmarshal(createStub.CapturedBody, &wrap); err != nil { + t.Fatalf("unmarshal captured body: %v (raw=%s)", err, string(createStub.CapturedBody)) + } + // HTTP body must be wrapped per IDL api.body="template": + // {"template": {"name": "...", "tos": [{...}]}} + captured, ok := wrap["template"].(map[string]interface{}) + if !ok { + t.Fatalf("captured body is not wrapped under \"template\": %s", string(createStub.CapturedBody)) + } + if got := captured["body_html"]; got != "

Body from CLI

" { + t.Fatalf("captured body_html = %#v, want the --body value", got) + } + if captured["name"] != "My Tpl" { + t.Errorf("captured name = %#v", captured["name"]) + } + if captured["subject"] != "hello" { + t.Errorf("captured subject = %#v", captured["subject"]) + } + tosList, ok := captured["tos"].([]interface{}) + if !ok || len(tosList) == 0 { + t.Fatalf("tos missing in captured template: %#v", captured["tos"]) + } + first, _ := tosList[0].(map[string]interface{}) + if first["mail_address"] != "alice@example.com" { + t.Errorf("tos[0].mail_address = %#v", first["mail_address"]) + } + + data := decodeShortcutEnvelopeData(t, stdout) + if data["template_id"] != "tpl_new" { + t.Errorf("template_id mismatch: %#v", data["template_id"]) + } +} + +func TestMailTemplateDelete_E2E(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/mail/v1/user_mailboxes/me/templates/tpl_1", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{}, + }, + }) + err := runMountedMailShortcut(t, MailTemplateDelete, []string{ + "+template-delete", + "--template-id", "tpl_1", + }, f, stdout) + if err != nil { + t.Fatalf("runMountedMailShortcut: %v", err) + } + data := decodeShortcutEnvelopeData(t, stdout) + if data["deleted"] != true { + t.Fatalf("expected deleted=true, got %#v", data) + } + if data["template_id"] != "tpl_1" { + t.Errorf("template_id mismatch: %#v", data["template_id"]) + } +} + +func TestMailTemplateGet_E2E(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/mail/v1/user_mailboxes/me/templates/tpl_1", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "template": map[string]interface{}{ + "template_id": "tpl_1", + "name": "周报", + "subject": "Weekly", + "body_html": "

hi

", + }, + }, + }, + }) + err := runMountedMailShortcut(t, MailTemplateGet, []string{ + "+template-get", + "--template-id", "tpl_1", + }, f, stdout) + if err != nil { + t.Fatalf("runMountedMailShortcut: %v", err) + } + data := decodeShortcutEnvelopeData(t, stdout) + if data["template_id"] != "tpl_1" { + t.Fatalf("template_id mismatch: %#v", data) + } + if data["name"] != "周报" { + t.Errorf("name mismatch: %#v", data["name"]) + } +} + +func TestMailTemplateUpdate_E2E_MergesBeforePut(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + // GET returns existing template + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/mail/v1/user_mailboxes/me/templates/tpl_1", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "template": map[string]interface{}{ + "template_id": "tpl_1", + "name": "old-name", + "subject": "old-subject", + "body_html": "

old

", + }, + }, + }, + }) + putStub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/mail/v1/user_mailboxes/me/templates/tpl_1", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "template": map[string]interface{}{"template_id": "tpl_1", "name": "new-name"}, + }, + }, + } + reg.Register(putStub) + + err := runMountedMailShortcut(t, MailTemplateUpdate, []string{ + "+template-update", + "--template-id", "tpl_1", + "--name", "new-name", + }, f, stdout) + if err != nil { + t.Fatalf("runMountedMailShortcut: %v", err) + } + + var wrap map[string]interface{} + if err := json.Unmarshal(putStub.CapturedBody, &wrap); err != nil { + t.Fatalf("unmarshal captured PUT body: %v", err) + } + // PUT body must be wrapped per IDL api.body="template". + captured, ok := wrap["template"].(map[string]interface{}) + if !ok { + t.Fatalf("captured PUT body is not wrapped under \"template\": %s", string(putStub.CapturedBody)) + } + if captured["name"] != "new-name" { + t.Errorf("PUT name = %#v, want new-name", captured["name"]) + } + // subject should be preserved from the GET response (merge semantics) + if captured["subject"] != "old-subject" { + t.Errorf("PUT should preserve unspecified subject, got %#v", captured["subject"]) + } + if captured["body_html"] != "

old

" { + t.Errorf("PUT should preserve unspecified body_html, got %#v", captured["body_html"]) + } +} diff --git a/shortcuts/mail/mail_template_update.go b/shortcuts/mail/mail_template_update.go new file mode 100644 index 000000000..dc56da8e5 --- /dev/null +++ b/shortcuts/mail/mail_template_update.go @@ -0,0 +1,117 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// MailTemplateUpdate updates an existing email template. +// +// The update semantic is GET-merge-PUT: we first fetch the existing template, +// overlay the user-provided flags, and then PUT the full object back. This +// gives callers a "partial update" feel even though the backend PUT semantics +// is full replacement. +var MailTemplateUpdate = common.Shortcut{ + Service: "mail", + Command: "+template-update", + Description: "Update an existing email template. Only specified fields are changed; omitted fields keep their current values (GET → merge → PUT).", + Risk: "write", + Scopes: []string{"mail:user_mailbox.message:modify"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "template-id", Desc: "Required. Template ID to update", Required: true}, + {Name: "name", Desc: "New template name"}, + {Name: "subject", Desc: "New email subject"}, + {Name: "body", Desc: "New email body (HTML or plain text). Written to body_html."}, + {Name: "to", Desc: "New default To recipients"}, + {Name: "cc", Desc: "New default CC recipients"}, + {Name: "bcc", Desc: "New default BCC recipients"}, + {Name: "plain-text", Type: "bool", Desc: "Force plain-text mode"}, + {Name: "mailbox", Default: "me", Desc: "Mailbox ID or email address (default: me)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("template-id")) == "" { + return output.ErrValidation("--template-id is required") + } + if name := strings.TrimSpace(runtime.Str("name")); name != "" && len([]rune(name)) > 100 { + return output.ErrValidation("--name exceeds 100 character limit") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + mailboxID := resolveMailboxID(runtime) + templateID := runtime.Str("template-id") + return common.NewDryRunAPI(). + Desc("Update an existing email template (GET → merge → PUT)"). + GET(mailboxPath(mailboxID, "templates", templateID)). + PUT(mailboxPath(mailboxID, "templates", templateID)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + mailboxID := resolveMailboxID(runtime) + templateID := runtime.Str("template-id") + + existing, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "templates", templateID), nil, nil) + if err != nil { + return output.Errorf(output.ExitAPI, "api_error", "get template failed: %s", err) + } + tmpl := extractTemplateObject(existing) + if tmpl == nil { + return output.Errorf(output.ExitAPI, "api_error", "template %s not found", templateID) + } + mergeTemplateUpdateFlags(runtime, tmpl) + + // Wrap the merged template object per IDL api.body="template" so the + // apigw routes the payload into the PUT request's Template field. + putBody := map[string]interface{}{"template": tmpl} + data, err := runtime.CallAPI("PUT", mailboxPath(mailboxID, "templates", templateID), nil, putBody) + if err != nil { + return output.Errorf(output.ExitAPI, "api_error", "update template failed: %s", err) + } + updated := extractTemplateObject(data) + if updated == nil { + updated = tmpl + } + runtime.OutFormat(updated, nil, func(w io.Writer) { + fmt.Fprintln(w, "Template updated.") + fmt.Fprintf(w, "template_id: %s\n", strVal(updated["template_id"])) + }) + return nil + }, +} + +// mergeTemplateUpdateFlags overlays user-provided flags onto an existing +// template object. It mutates tmpl in place. +func mergeTemplateUpdateFlags(runtime *common.RuntimeContext, tmpl map[string]interface{}) { + if n := runtime.Str("name"); n != "" { + tmpl["name"] = n + } + if s := runtime.Str("subject"); s != "" { + tmpl["subject"] = s + } + if b := runtime.Str("body"); b != "" { + tmpl["body_html"] = b + } + if runtime.Bool("plain-text") { + tmpl["is_plain_text_mode"] = true + } + // Address list JSON keys align with the IDL Template struct + // (api.json="tos"/"ccs"/"bccs"); CLI flags remain singular. + if to := runtime.Str("to"); to != "" { + tmpl["tos"] = parseAddressListForAPI(to) + } + if cc := runtime.Str("cc"); cc != "" { + tmpl["ccs"] = parseAddressListForAPI(cc) + } + if bcc := runtime.Str("bcc"); bcc != "" { + tmpl["bccs"] = parseAddressListForAPI(bcc) + } +} diff --git a/shortcuts/mail/shortcuts.go b/shortcuts/mail/shortcuts.go index ef05b37a7..a39619c89 100644 --- a/shortcuts/mail/shortcuts.go +++ b/shortcuts/mail/shortcuts.go @@ -20,5 +20,12 @@ func Shortcuts() []common.Shortcut { MailDraftEdit, MailForward, MailSignature, + // Template management + MailTemplateList, + MailTemplateGet, + MailTemplateCreate, + MailTemplateUpdate, + MailTemplateDelete, + MailTemplateSend, } } diff --git a/skills/lark-mail/references/lark-mail-template.md b/skills/lark-mail/references/lark-mail-template.md new file mode 100644 index 000000000..601c50a75 --- /dev/null +++ b/skills/lark-mail/references/lark-mail-template.md @@ -0,0 +1,212 @@ +# mail +template-* + +> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +飞书邮箱模板(Email Template)CRUD 命令集合,以及基于模板创建草稿/发送邮件的一键命令。本 skill 对应以下 6 个 shortcut: + +- `lark-cli mail +template-list` +- `lark-cli mail +template-get` +- `lark-cli mail +template-create` +- `lark-cli mail +template-update` +- `lark-cli mail +template-delete` +- `lark-cli mail +template-send` + +## 命令概览 + +| 命令 | 说明 | Risk | `--as` | +|------|------|------|--------| +| `+template-list` | 列出当前邮箱的所有邮件模板 | read | user | +| `+template-get` | 获取模板详情 | read | user | +| `+template-create` | 创建新模板 | write | user | +| `+template-update` | 更新已有模板(GET → merge → PUT) | write | user | +| `+template-delete` | 删除模板 | delete | user | +| `+template-send` | 基于模板创建草稿/发送邮件 | write | user | + +## 参数 + +### `+template-list` + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--mailbox ` | 否 | 邮箱 ID 或邮箱地址(默认 `me`) | + +### `+template-get` + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--template-id ` | 是 | 模板 ID | +| `--mailbox ` | 否 | 邮箱 ID 或邮箱地址(默认 `me`) | + +### `+template-create` + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--name ` | 是 | 模板名称(≤ 100 字符) | +| `--subject ` | 否 | 邮件主题 | +| `--body ` | 否 | 邮件正文(HTML 或纯文本,自动识别)。该值会原样写入请求体 `template.body_html` 字段 | +| `--to ` | 否 | 默认收件人(逗号分隔,支持 `Name ` 格式) | +| `--cc ` | 否 | 默认抄送 | +| `--bcc ` | 否 | 默认密送 | +| `--plain-text` | 否 | 强制纯文本模式(`is_plain_text_mode=true`) | +| `--mailbox ` | 否 | 邮箱 ID(默认 `me`) | + +### `+template-update` + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--template-id ` | 是 | 待更新的模板 ID | +| `--name`, `--subject`, `--body`, `--to`, `--cc`, `--bcc`, `--plain-text`, `--mailbox` | 否 | 仅传入的字段会覆盖现有值,省略的字段保持原样(CLI 层会先 GET 再 PUT) | + +### `+template-delete` + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--template-id ` | 是 | 待删除的模板 ID | +| `--mailbox ` | 否 | 邮箱 ID(默认 `me`) | + +### `+template-send` + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--template-id ` | 是 | 模板 ID | +| `--to ` | 否 | 覆盖模板默认 To。若模板默认收件人为空且未传该参数,命令会报错 | +| `--subject ` | 否 | 覆盖模板主题 | +| `--body ` | 否 | 覆盖模板正文 | +| `--cc ` | 否 | 覆盖抄送 | +| `--bcc ` | 否 | 覆盖密送 | +| `--from ` | 否 | 发件人地址(默认使用当前邮箱的主地址) | +| `--confirm-send` | 否 | 立即发送(默认只保存草稿,需要 `mail:user_mailbox.message:send` scope) | +| `--mailbox ` | 否 | 邮箱 ID(默认 `me`) | + +## 典型工作流 + +### 1. 查看我的模板 + +```bash +lark-cli mail +template-list --as user +``` + +### 2. 创建新模板 + +```bash +lark-cli mail +template-create --as user \ + --name "周报模板" \ + --subject "周报 - {{date}}" \ + --body "

本周工作

  • ...
" +``` + +### 3. 用模板发邮件(默认保存为草稿) + +```bash +# 先拿到 template_id +lark-cli mail +template-list --as user + +# 基于模板创建草稿 +lark-cli mail +template-send --as user \ + --template-id \ + --to "alice@example.com" + +# 审核草稿后,使用 --confirm-send 发送 +lark-cli mail +template-send --as user \ + --template-id \ + --to "alice@example.com" \ + --confirm-send +``` + +### 4. 部分更新模板 + +```bash +# 仅改名,其他字段保持不变 +lark-cli mail +template-update --as user \ + --template-id \ + --name "每周周报" + +# 仅改收件人 +lark-cli mail +template-update --as user \ + --template-id \ + --to "hr@company.com" +``` + +### 5. 删除不用的模板 + +```bash +lark-cli mail +template-delete --as user --template-id +``` + +## 返回值 + +**`+template-list`:** + +```json +{ + "ok": true, + "data": { + "items": [ + { + "template_id": "", + "name": "周报模板", + "subject": "周报 - {{date}}", + "body_html": "

本周工作

...", + "create_time": "1700000000000" + } + ] + } +} +``` + +**`+template-send`(默认,仅创建草稿):** + +```json +{ + "ok": true, + "data": { + "draft_id": "", + "template_id": "", + "tip": "draft saved from template. To send: lark-cli mail user_mailbox.drafts send --params ..." + } +} +``` + +**`+template-send` + `--confirm-send`:** + +```json +{ + "ok": true, + "data": { + "message_id": "", + "thread_id": "", + "template_id": "" + } +} +``` + +## 请求体格式(CREATE / UPDATE) + +CLI 在发出 POST/PUT 请求时会将模板字段包裹在 `template` 下,以对齐 IDL `api.body="template"` 契约。地址数组字段 JSON key 为 `tos`/`ccs`/`bccs`(CLI 侧仍用 `--to`/`--cc`/`--bcc`)。 + +```json +{ + "template": { + "name": "周报模板", + "subject": "周报 - {{date}}", + "body_html": "

本周工作

  • ...
", + "is_plain_text_mode": false, + "tos": [{"mail_address": "alice@example.com", "name": "Alice"}], + "ccs": [{"mail_address": "bob@example.com"}], + "bccs": [] + } +} +``` + +## 注意事项 + +- **身份**:模板操作仅支持 `--as user`(UAT)。Bot 身份(TAT)暂不支持。 +- **数量上限**:每个用户最多创建 20 个模板;超限时 Open API 会返回错误码 `150802 TEMPLATE_NUMBER_LIMIT`。 +- **名称长度**:模板名称最长 100 字符(按 rune 计数,中文也按 1 个字符算)。 +- **正文大小**:单个模板 ≤ 3 MB;所有模板合计 ≤ 50 MB。 +- **更新语义**:`+template-update` 在 CLI 层做 GET → merge → PUT,省略的字段保持原值;在后端实际上是全量替换。 +- **发送默认草稿**:`+template-send` 默认只保存草稿,需要 `--confirm-send` 才会调用 `drafts.send`,这是为了保护 AI Agent 误发邮件。 +- **权限**: + - 只读(list/get):`mail:user_mailbox.message:readonly` + - 写(create/update/delete):`mail:user_mailbox.message:modify` + - 发送(`+template-send --confirm-send`)还需要 `mail:user_mailbox.message:send`