diff --git a/shortcuts/mail/mail_signature_create.go b/shortcuts/mail/mail_signature_create.go new file mode 100644 index 000000000..2482920f6 --- /dev/null +++ b/shortcuts/mail/mail_signature_create.go @@ -0,0 +1,118 @@ +// 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" + "github.com/larksuite/cli/shortcuts/mail/signature" +) + +var MailSignatureCreate = common.Shortcut{ + Service: "mail", + Command: "+signature-create", + Description: "Create a personal USER mail signature. HTML content is preserved; plain text is wrapped for email rendering. Local image paths are not uploaded.", + Risk: "write", + Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox:readonly"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "mailbox", Desc: "Mailbox address that owns the signature (default: me)."}, + {Name: "name", Desc: "Required. Signature name (≤100 chars).", Required: true}, + {Name: "content", Desc: "Signature content. HTML is preserved; plain text is wrapped for email rendering."}, + {Name: "content-file", Desc: "Relative file path for signature content. Mutually exclusive with --content."}, + {Name: "device", Default: "pc", Desc: "Signature device: pc or mobile."}, + {Name: "images-json", Desc: "JSON array of signature image metadata. cid values must match references."}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + mailboxID := resolveComposeMailboxID(runtime) + content, _, err := resolveSignatureContent(runtime, "content", "content-file") + contentSource := "inline" + if runtime.Str("content-file") != "" { + contentSource = runtime.Str("content-file") + } + api := common.NewDryRunAPI(). + Desc("Create a personal USER mail signature. The command validates content/images locally, then POSTs a signature wrapper."). + POST(mailboxPath(mailboxID, "settings", "signatures")). + Body(map[string]interface{}{ + "signature": map[string]interface{}{ + "name": strings.TrimSpace(runtime.Str("name")), + "content_source": contentSource, + "content_preview": contentPreview(content, 120, resolveLang(runtime)), + "signature_device": strings.ToUpper(runtime.Str("device")), + "images": "", + "signature_type": "USER", + }, + }) + if err != nil { + api.Set("validation_warning", err.Error()) + } + return api + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("name")) == "" { + return output.ErrValidation("--name is required") + } + if len([]rune(strings.TrimSpace(runtime.Str("name")))) > 100 { + return output.ErrValidation("--name must be at most 100 characters") + } + if runtime.Changed("content") && runtime.Str("content-file") != "" { + return output.ErrValidation("--content and --content-file are mutually exclusive") + } + if _, err := normalizeSignatureDevice(runtime.Str("device"), "--device"); err != nil { + return err + } + content, _, err := resolveSignatureContent(runtime, "content", "content-file") + if err != nil { + return err + } + images, _, err := parseSignatureImagesJSON(runtime.Str("images-json"), "images-json") + if err != nil { + return err + } + return validateSignatureImageRefs(content, images) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + mailboxID := resolveComposeMailboxID(runtime) + device, err := normalizeSignatureDevice(runtime.Str("device"), "--device") + if err != nil { + return err + } + content, _, err := resolveSignatureContent(runtime, "content", "content-file") + if err != nil { + return err + } + images, _, err := parseSignatureImagesJSON(runtime.Str("images-json"), "images-json") + if err != nil { + return err + } + if err := validateSignatureImageRefs(content, images); err != nil { + return err + } + + created, err := signature.Create(runtime, mailboxID, signature.Signature{ + Name: strings.TrimSpace(runtime.Str("name")), + SignatureType: signature.SignatureTypeUser, + SignatureDevice: device, + Content: content, + Images: sanitizeSignatureImages(images), + }) + if err != nil { + return err + } + out := signatureOutput(created, resolveLang(runtime)) + delete(out, "changed_fields") + delete(out, "last_write_policy") + runtime.OutFormat(out, nil, func(w io.Writer) { + formatSignatureSummary(w, "created", created, resolveLang(runtime)) + fmt.Fprintln(w, "Use this signature_id with mail +send / +reply / +forward --signature-id.") + }) + return nil + }, +} diff --git a/shortcuts/mail/mail_signature_delete.go b/shortcuts/mail/mail_signature_delete.go new file mode 100644 index 000000000..59b0fbb0b --- /dev/null +++ b/shortcuts/mail/mail_signature_delete.go @@ -0,0 +1,86 @@ +// 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" + "github.com/larksuite/cli/shortcuts/mail/signature" +) + +var MailSignatureDelete = common.Shortcut{ + Service: "mail", + Command: "+signature-delete", + Description: "Delete a personal USER mail signature after GET validation. Requires --yes for non-interactive safety.", + Risk: "high-risk-write", + Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox:readonly"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "mailbox", Desc: "Mailbox address that owns the signature (default: me)."}, + {Name: "signature-id", Desc: "USER signature ID to delete.", Required: true}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + mailboxID := resolveComposeMailboxID(runtime) + signatureID := runtime.Str("signature-id") + return common.NewDryRunAPI(). + Desc("Delete a personal USER mail signature: GET current signatures, reject TENANT or missing targets, DELETE the signature, then best-effort GET to verify absence. Default signature application refs are cleared by the server."). + GET(mailboxPath(mailboxID, "settings", "signatures")). + DELETE(mailboxPath(mailboxID, "settings", "signatures", signatureID)). + GET(mailboxPath(mailboxID, "settings", "signatures")) + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateSignatureID(runtime.Str("signature-id")); err != nil { + return err + } + if runtime.Str("signature-id") == "" { + return output.ErrValidation("--signature-id is required") + } + if !runtime.Bool("dry-run") && !runtime.Bool("yes") { + return output.ErrValidation("--yes is required to delete a signature in non-interactive mode") + } + return nil + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + mailboxID := resolveComposeMailboxID(runtime) + signatureID := runtime.Str("signature-id") + current, err := findSignature(runtime, mailboxID, signatureID) + if err != nil { + return err + } + if current.SignatureType != signature.SignatureTypeUser { + return output.ErrValidation("only USER signatures can be deleted") + } + if err := signature.Delete(runtime, mailboxID, signatureID); err != nil { + return err + } + verifyStatus := "unknown" + if resp, err := signature.ListAll(runtime, mailboxID); err == nil { + verifyStatus = "absent" + for _, sig := range resp.Signatures { + if sig.ID == signatureID { + verifyStatus = "unknown" + break + } + } + } + out := map[string]interface{}{ + "deleted": true, + "deleted_signature_id": signatureID, + "name": current.Name, + "signature_device": current.SignatureDevice, + "verify_status": verifyStatus, + } + runtime.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintln(w, "Signature deleted.") + fmt.Fprintf(w, "deleted_signature_id: %s\n", signatureID) + fmt.Fprintf(w, "verify_status: %s\n", verifyStatus) + }) + return nil + }, +} diff --git a/shortcuts/mail/mail_signature_update.go b/shortcuts/mail/mail_signature_update.go new file mode 100644 index 000000000..15ba9277e --- /dev/null +++ b/shortcuts/mail/mail_signature_update.go @@ -0,0 +1,344 @@ +// 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" + "github.com/larksuite/cli/shortcuts/mail/signature" +) + +var MailSignatureUpdate = common.Shortcut{ + Service: "mail", + Command: "+signature-update", + Description: "Update a personal USER mail signature with GET + local merge + full-replace PUT. Supports inspect, patch-file, flat --set-* flags, and last-write-wins dry-runs.", + Risk: "write", + Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox:readonly"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "mailbox", Desc: "Mailbox address that owns the signature (default: me)."}, + {Name: "signature-id", Desc: "Signature ID to update. Required except with --print-patch-template.", Required: false}, + {Name: "inspect", Type: "bool", Desc: "Inspect the current signature without updating it."}, + {Name: "print-patch-template", Type: "bool", Desc: "Print a JSON skeleton for --patch-file. No network call is made."}, + {Name: "patch-file", Desc: "Relative JSON patch file. Shape is the same as --print-patch-template output."}, + {Name: "set-name", Desc: "Replace signature name (≤100 chars)."}, + {Name: "set-content", Desc: "Replace signature content. HTML is preserved; plain text is wrapped."}, + {Name: "set-content-file", Desc: "Relative file path for replacement content. Mutually exclusive with --set-content."}, + {Name: "set-device", Desc: "Replace signature device: pc or mobile."}, + {Name: "set-images-json", Desc: "Replace images metadata JSON array. Pass [] to clear images."}, + }, + 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("signature", buildSignaturePatchSkeleton()) + } + mailboxID := resolveComposeMailboxID(runtime) + signatureID := runtime.Str("signature-id") + if signatureID == "" { + return common.NewDryRunAPI().Set("error", "--signature-id is required except with --print-patch-template") + } + if runtime.Bool("inspect") { + return common.NewDryRunAPI(). + Desc("Inspect the signature without modifying it."). + GET(mailboxPath(mailboxID, "settings", "signatures")) + } + return common.NewDryRunAPI(). + Desc("Update a personal USER mail signature: GET current signatures, merge --patch-file and --set-* flags, then PUT a full-replace signature. No optimistic locking; concurrent updates are last-write-wins."). + GET(mailboxPath(mailboxID, "settings", "signatures")). + PUT(mailboxPath(mailboxID, "settings", "signatures", signatureID)). + Body(map[string]interface{}{ + "signature": "", + "changed_fields": dryRunSignatureChangedFields(runtime), + "_warning": "No optimistic locking — last write wins.", + }) + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if runtime.Bool("print-patch-template") { + return nil + } + if err := validateSignatureID(runtime.Str("signature-id")); err != nil { + return err + } + if runtime.Str("signature-id") == "" { + return output.ErrValidation("--signature-id is required (or use --print-patch-template to print the patch skeleton)") + } + if runtime.Changed("set-content") && runtime.Str("set-content-file") != "" { + return output.ErrValidation("--set-content and --set-content-file are mutually exclusive") + } + if runtime.Changed("set-device") { + if _, err := normalizeSignatureDevice(runtime.Str("set-device"), "--set-device"); err != nil { + return err + } + } + if name := runtime.Str("set-name"); runtime.Changed("set-name") { + if strings.TrimSpace(name) == "" { + return output.ErrValidation("--set-name must not be empty") + } + if len([]rune(strings.TrimSpace(name))) > 100 { + return output.ErrValidation("--set-name must be at most 100 characters") + } + } + if _, _, err := resolveSignatureContent(runtime, "set-content", "set-content-file"); err != nil { + return err + } + if _, _, err := parseSignatureImagesJSON(runtime.Str("set-images-json"), "set-images-json"); err != nil { + return err + } + patch, err := loadSignaturePatchFile(runtime) + if err != nil { + return err + } + if patch != nil { + if patch.ID != nil && *patch.ID != "" && *patch.ID != runtime.Str("signature-id") { + return output.ErrValidation("signature id in body must match path") + } + if patch.Name != nil { + if strings.TrimSpace(*patch.Name) == "" { + return output.ErrValidation("patch name must not be empty") + } + if len([]rune(strings.TrimSpace(*patch.Name))) > 100 { + return output.ErrValidation("patch name must be at most 100 characters") + } + } + if patch.Content != nil { + if _, err := normalizeSignatureContent(*patch.Content); err != nil { + return err + } + } + if patch.SignatureDevice != nil { + if _, err := normalizeSignatureDevice(*patch.SignatureDevice, "patch signature_device"); err != nil { + return err + } + } + if patch.Images != nil { + if err := validateSignatureImagesOnly(*patch.Images); err != nil { + return err + } + } + } + return nil + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + if runtime.Bool("print-patch-template") { + runtime.Out(buildSignaturePatchSkeleton(), nil) + return nil + } + mailboxID := resolveComposeMailboxID(runtime) + signatureID := runtime.Str("signature-id") + current, err := findSignature(runtime, mailboxID, signatureID) + if err != nil { + return err + } + if runtime.Bool("inspect") { + out := signatureOutput(current, resolveLang(runtime)) + delete(out, "changed_fields") + delete(out, "last_write_policy") + runtime.OutFormat(out, nil, func(w io.Writer) { + formatSignatureSummary(w, "inspection (read-only)", current, resolveLang(runtime)) + }) + return nil + } + if current.SignatureType != signature.SignatureTypeUser { + return output.ErrValidation("only USER signatures can be updated") + } + + next := *current + next.ID = signatureID + next.SignatureType = signature.SignatureTypeUser + next.Images = sanitizeSignatureImages(next.Images) + changedFields := []string{} + + patch, err := loadSignaturePatchFile(runtime) + if err != nil { + return err + } + if patch != nil { + if patch.ID != nil && *patch.ID != "" && *patch.ID != signatureID { + return output.ErrValidation("signature id in body must match path") + } + if patch.Name != nil { + next.Name = strings.TrimSpace(*patch.Name) + changedFields = append(changedFields, "name") + } + if patch.Content != nil { + content, err := normalizeSignatureContent(*patch.Content) + if err != nil { + return err + } + next.Content = content + changedFields = append(changedFields, "content") + } + if patch.SignatureDevice != nil { + device, err := normalizeSignatureDevice(*patch.SignatureDevice, "patch signature_device") + if err != nil { + return err + } + next.SignatureDevice = device + changedFields = append(changedFields, "signature_device") + } + if patch.Images != nil { + next.Images = sanitizeSignatureImages(*patch.Images) + changedFields = append(changedFields, "images") + } + } + + if runtime.Changed("set-name") { + next.Name = strings.TrimSpace(runtime.Str("set-name")) + changedFields = append(changedFields, "name") + } + if content, changed, err := resolveSignatureContent(runtime, "set-content", "set-content-file"); err != nil { + return err + } else if changed { + next.Content = content + changedFields = append(changedFields, "content") + } + if runtime.Changed("set-device") { + device, err := normalizeSignatureDevice(runtime.Str("set-device"), "--set-device") + if err != nil { + return err + } + next.SignatureDevice = device + changedFields = append(changedFields, "signature_device") + } + if runtime.Changed("set-images-json") { + images, _, err := parseSignatureImagesJSON(runtime.Str("set-images-json"), "set-images-json") + if err != nil { + return err + } + next.Images = sanitizeSignatureImages(images) + changedFields = append(changedFields, "images") + } + + if strings.TrimSpace(next.Name) == "" { + return output.ErrValidation("signature name must not be empty") + } + if len([]rune(strings.TrimSpace(next.Name))) > 100 { + return output.ErrValidation("signature name must be at most 100 characters") + } + next.Name = strings.TrimSpace(next.Name) + if next.SignatureDevice == "" { + next.SignatureDevice = signature.DevicePC + } + if err := validateSignatureImageRefs(next.Content, next.Images); err != nil { + return err + } + + updated, err := signature.Update(runtime, mailboxID, signatureID, next) + if err != nil { + return err + } + out := signatureOutput(updated, resolveLang(runtime)) + out["changed_fields"] = uniqueSignatureFields(changedFields) + runtime.OutFormat(out, nil, func(w io.Writer) { + formatSignatureSummary(w, "updated", updated, resolveLang(runtime)) + fmt.Fprintln(w, "warning: no optimistic locking; concurrent updates are last-write-wins.") + }) + fmt.Fprintln(runtime.IO().ErrOut, + "warning: signature endpoints have no optimistic locking; concurrent updates are last-write-wins.") + return nil + }, +} + +type signaturePatchFile struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Content *string `json:"content,omitempty"` + SignatureDevice *string `json:"signature_device,omitempty"` + Images *[]signature.SignatureImage `json:"images,omitempty"` +} + +func loadSignaturePatchFile(runtime *common.RuntimeContext) (*signaturePatchFile, error) { + pf := strings.TrimSpace(runtime.Str("patch-file")) + if pf == "" { + return nil, nil + } + f, err := runtime.FileIO().Open(pf) + if err != nil { + return nil, output.ErrValidation("open --patch-file %s: %v", pf, err) + } + buf, readErr := io.ReadAll(f) + f.Close() + if readErr != nil { + return nil, output.ErrValidation("read --patch-file %s: %v", pf, readErr) + } + var patch signaturePatchFile + if err := json.Unmarshal(buf, &patch); err != nil { + return nil, output.ErrValidation("parse --patch-file %s: %v", pf, err) + } + if patch.Images != nil { + *patch.Images = sanitizeSignatureImages(*patch.Images) + } + return &patch, nil +} + +func buildSignaturePatchSkeleton() map[string]interface{} { + return map[string]interface{}{ + "id": "string (must match --signature-id when present)", + "name": "string (≤100 chars, optional)", + "content": "string (HTML or plain text; local paths are not uploaded)", + "signature_device": "PC or MOBILE", + "images": []map[string]interface{}{{ + "image_name": "logo.png", + "file_key": "file_key from an already uploaded signature image", + "cid": "logo1", + "file_size": "12345", + "image_width": 120, + "image_height": 48, + }}, + } +} + +func findSignature(runtime *common.RuntimeContext, mailboxID, signatureID string) (*signature.Signature, error) { + resp, err := signature.ListAll(runtime, mailboxID) + if err != nil { + return nil, err + } + for i := range resp.Signatures { + if resp.Signatures[i].ID == signatureID { + return &resp.Signatures[i], nil + } + } + return nil, output.ErrValidation("signature not found or already deleted: %s", signatureID) +} + +func dryRunSignatureChangedFields(runtime *common.RuntimeContext) []string { + fields := []string{} + if runtime.Str("patch-file") != "" { + fields = append(fields, "patch-file") + } + if runtime.Changed("set-name") { + fields = append(fields, "name") + } + if runtime.Changed("set-content") || runtime.Str("set-content-file") != "" { + fields = append(fields, "content") + } + if runtime.Changed("set-device") { + fields = append(fields, "signature_device") + } + if runtime.Changed("set-images-json") { + fields = append(fields, "images") + } + return uniqueSignatureFields(fields) +} + +func uniqueSignatureFields(fields []string) []string { + seen := map[string]bool{} + out := []string{} + for _, field := range fields { + if field == "" || seen[field] { + continue + } + seen[field] = true + out = append(out, field) + } + return out +} diff --git a/shortcuts/mail/mail_signature_write_test.go b/shortcuts/mail/mail_signature_write_test.go new file mode 100644 index 000000000..195d86304 --- /dev/null +++ b/shortcuts/mail/mail_signature_write_test.go @@ -0,0 +1,323 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "os" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/mail/signature" +) + +func clearSignatureTestCache() { + signature.ClearCache("me") +} + +func TestMailSignatureCreate_Happy(t *testing.T) { + clearSignatureTestCache() + f, stdout, _, reg := mailShortcutTestFactory(t) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/user_mailboxes/me/settings/signatures", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "signature": map[string]interface{}{ + "id": "123", + "name": "Agent", + "signature_type": "USER", + "signature_device": "PC", + "content": "

Regards

", + "images": []map[string]interface{}{{ + "image_name": "logo.png", + "file_key": "file_1", + "cid": "logo1", + }}, + }, + }, + }, + } + reg.Register(stub) + + err := runMountedMailShortcut(t, MailSignatureCreate, []string{ + "+signature-create", + "--name", "Agent", + "--content", "

Regards

", + "--images-json", `[{"image_name":"logo.png","file_key":"file_1","cid":"logo1","download_url":"ignored"}]`, + }, f, stdout) + if err != nil { + t.Fatalf("signature-create failed: %v", err) + } + + capturedBody := decodeCapturedBody(t, stub) + sig := capturedBody["signature"].(map[string]interface{}) + if sig["signature_type"] != "USER" { + t.Fatalf("signature_type = %v", sig["signature_type"]) + } + if sig["signature_device"] != "PC" { + t.Fatalf("signature_device = %v", sig["signature_device"]) + } + images := sig["images"].([]interface{}) + img := images[0].(map[string]interface{}) + if _, ok := img["download_url"]; ok { + t.Fatalf("download_url should not be sent in write payload: %#v", img) + } + + data := decodeShortcutEnvelopeData(t, stdout) + if data["id"] != "123" { + t.Fatalf("id = %v", data["id"]) + } + if data["content_preview"] == "" { + t.Fatalf("content_preview missing: %#v", data) + } +} + +func TestMailSignatureCreate_ContentFilePlainTextWrap(t *testing.T) { + clearSignatureTestCache() + chdirTemp(t) + if err := os.WriteFile("sig.txt", []byte("Regards\nAlice"), 0o644); err != nil { + t.Fatal(err) + } + f, stdout, _, reg := mailShortcutTestFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/user_mailboxes/me/settings/signatures", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "signature": map[string]interface{}{ + "id": "124", + "name": "Plain", + "signature_type": "USER", + "signature_device": "MOBILE", + "content": "
Regards
Alice
", + }, + }, + }, + } + reg.Register(stub) + + err := runMountedMailShortcut(t, MailSignatureCreate, []string{ + "+signature-create", + "--name", "Plain", + "--content-file", "sig.txt", + "--device", "mobile", + }, f, stdout) + if err != nil { + t.Fatalf("signature-create failed: %v", err) + } + body := decodeCapturedBody(t, stub) + content := body["signature"].(map[string]interface{})["content"].(string) + if !strings.Contains(content, "Regards
Alice") { + t.Fatalf("plain text content was not wrapped: %s", content) + } + if body["signature"].(map[string]interface{})["signature_device"] != "MOBILE" { + t.Fatalf("device not normalized: %#v", body) + } +} + +func TestMailSignatureCreate_ValidateImages(t *testing.T) { + clearSignatureTestCache() + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailSignatureCreate, []string{ + "+signature-create", + "--name", "Bad", + "--content", `

`, + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "local image paths are not supported") { + t.Fatalf("expected local image validation error, got %v", err) + } + + err = runMountedMailShortcut(t, MailSignatureCreate, []string{ + "+signature-create", + "--name", "Bad", + "--content", `

`, + "--images-json", `[{"cid":"other","file_key":"file_1"}]`, + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "not referenced") { + t.Fatalf("expected cid mismatch validation error, got %v", err) + } +} + +func TestMailSignatureUpdate_Happy(t *testing.T) { + clearSignatureTestCache() + f, stdout, _, reg := mailShortcutTestFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/user_mailboxes/me/settings/signatures", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "signatures": []map[string]interface{}{{ + "id": "123", + "name": "Old", + "signature_type": "USER", + "signature_device": "PC", + "content": "

old

", + }}, + }, + }, + }) + putStub := &httpmock.Stub{ + Method: "PUT", + URL: "/user_mailboxes/me/settings/signatures/123", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "signature": map[string]interface{}{ + "id": "123", + "name": "New", + "signature_type": "USER", + "signature_device": "MOBILE", + "content": "

new

", + }, + }, + }, + } + reg.Register(putStub) + + err := runMountedMailShortcut(t, MailSignatureUpdate, []string{ + "+signature-update", + "--signature-id", "123", + "--set-name", "New", + "--set-device", "mobile", + "--set-content", "

new

", + }, f, stdout) + if err != nil { + t.Fatalf("signature-update failed: %v", err) + } + + body := decodeCapturedBody(t, putStub) + sig := body["signature"].(map[string]interface{}) + if sig["id"] != "123" || sig["name"] != "New" || sig["signature_device"] != "MOBILE" { + t.Fatalf("unexpected PUT signature: %#v", sig) + } + data := decodeShortcutEnvelopeData(t, stdout) + if data["id"] != "123" { + t.Fatalf("id = %v", data["id"]) + } +} + +func TestMailSignatureUpdate_InspectAndTenantReject(t *testing.T) { + clearSignatureTestCache() + f, stdout, _, reg := mailShortcutTestFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/user_mailboxes/me/settings/signatures", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "signatures": []map[string]interface{}{{ + "id": "123", + "name": "Tenant", + "signature_type": "TENANT", + "signature_device": "PC", + "content": "

tenant

", + }}, + }, + }, + Reusable: true, + }) + err := runMountedMailShortcut(t, MailSignatureUpdate, []string{ + "+signature-update", + "--signature-id", "123", + "--inspect", + }, f, stdout) + if err != nil { + t.Fatalf("inspect should pass for TENANT: %v", err) + } + + err = runMountedMailShortcut(t, MailSignatureUpdate, []string{ + "+signature-update", + "--signature-id", "123", + "--set-name", "Nope", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "only USER signatures") { + t.Fatalf("expected TENANT rejection, got %v", err) + } +} + +func TestMailSignatureUpdate_PatchIDConflict(t *testing.T) { + clearSignatureTestCache() + chdirTemp(t) + if err := os.WriteFile("patch.json", []byte(`{"id":"999","name":"New"}`), 0o644); err != nil { + t.Fatal(err) + } + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailSignatureUpdate, []string{ + "+signature-update", + "--signature-id", "123", + "--patch-file", "patch.json", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "signature id in body must match path") { + t.Fatalf("expected patch id conflict, got %v", err) + } +} + +func TestMailSignatureDelete_Happy(t *testing.T) { + clearSignatureTestCache() + f, stdout, _, reg := mailShortcutTestFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/user_mailboxes/me/settings/signatures", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "signatures": []map[string]interface{}{{ + "id": "123", + "name": "Agent", + "signature_type": "USER", + "signature_device": "PC", + "content": "

bye

", + }}, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/user_mailboxes/me/settings/signatures/123", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/user_mailboxes/me/settings/signatures", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "signatures": []map[string]interface{}{}, + }, + }, + }) + + err := runMountedMailShortcut(t, MailSignatureDelete, []string{ + "+signature-delete", + "--signature-id", "123", + "--yes", + }, f, stdout) + if err != nil { + t.Fatalf("signature-delete failed: %v", err) + } + data := decodeShortcutEnvelopeData(t, stdout) + if data["deleted_signature_id"] != "123" || data["verify_status"] != "absent" { + t.Fatalf("unexpected delete output: %#v", data) + } +} + +func TestMailSignatureDelete_RequiresYes(t *testing.T) { + clearSignatureTestCache() + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailSignatureDelete, []string{ + "+signature-delete", + "--signature-id", "123", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "--yes is required") { + t.Fatalf("expected --yes validation error, got %v", err) + } +} diff --git a/shortcuts/mail/shortcuts.go b/shortcuts/mail/shortcuts.go index 8bd7a7f01..5bc410013 100644 --- a/shortcuts/mail/shortcuts.go +++ b/shortcuts/mail/shortcuts.go @@ -22,6 +22,9 @@ func Shortcuts() []common.Shortcut { MailSendReceipt, MailDeclineReceipt, MailSignature, + MailSignatureCreate, + MailSignatureUpdate, + MailSignatureDelete, MailShareToChat, MailTemplateCreate, MailTemplateUpdate, diff --git a/shortcuts/mail/signature/model.go b/shortcuts/mail/signature/model.go index de2cac504..b7a71e865 100644 --- a/shortcuts/mail/signature/model.go +++ b/shortcuts/mail/signature/model.go @@ -80,3 +80,13 @@ type GetSignaturesResponse struct { Signatures []Signature `json:"signatures"` Usages []SignatureUsage `json:"usages"` } + +// WriteSignatureRequest wraps a signature object for create/update APIs. +type WriteSignatureRequest struct { + Signature Signature `json:"signature"` +} + +// WriteSignatureResponse is returned by create/update APIs. +type WriteSignatureResponse struct { + Signature Signature `json:"signature"` +} diff --git a/shortcuts/mail/signature/provider.go b/shortcuts/mail/signature/provider.go index 1dda66960..2a1f06b92 100644 --- a/shortcuts/mail/signature/provider.go +++ b/shortcuts/mail/signature/provider.go @@ -68,3 +68,52 @@ func Get(runtime *common.RuntimeContext, mailboxID, signatureID string) (*Signat } return nil, fmt.Errorf("signature not found: %s", signatureID) } + +// Create creates a USER signature and clears the per-process list cache. +func Create(runtime *common.RuntimeContext, mailboxID string, sig Signature) (*Signature, error) { + data, err := runtime.CallAPI("POST", signaturesPath(mailboxID), nil, WriteSignatureRequest{Signature: sig}) + if err != nil { + return nil, fmt.Errorf("create signature: %w", err) + } + delete(processCache, mailboxID) + return decodeWriteSignature("create signature", data) +} + +// Update full-replaces a USER signature and clears the per-process list cache. +func Update(runtime *common.RuntimeContext, mailboxID, signatureID string, sig Signature) (*Signature, error) { + data, err := runtime.CallAPI("PUT", signaturesPath(mailboxID)+"/"+url.PathEscape(signatureID), nil, WriteSignatureRequest{Signature: sig}) + if err != nil { + return nil, fmt.Errorf("update signature: %w", err) + } + delete(processCache, mailboxID) + return decodeWriteSignature("update signature", data) +} + +// Delete deletes a USER signature and clears the per-process list cache. +func Delete(runtime *common.RuntimeContext, mailboxID, signatureID string) error { + if _, err := runtime.CallAPI("DELETE", signaturesPath(mailboxID)+"/"+url.PathEscape(signatureID), nil, nil); err != nil { + return fmt.Errorf("delete signature: %w", err) + } + delete(processCache, mailboxID) + return nil +} + +// ClearCache clears the per-process signature cache for a mailbox. +func ClearCache(mailboxID string) { + delete(processCache, mailboxID) +} + +func decodeWriteSignature(op string, data map[string]interface{}) (*Signature, error) { + raw, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("%s: marshal response: %w", op, err) + } + var resp WriteSignatureResponse + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("%s: unmarshal response: %w", op, err) + } + if resp.Signature.ID == "" { + return nil, fmt.Errorf("%s: response signature.id is empty", op) + } + return &resp.Signature, nil +} diff --git a/shortcuts/mail/signature_content.go b/shortcuts/mail/signature_content.go new file mode 100644 index 000000000..20962d129 --- /dev/null +++ b/shortcuts/mail/signature_content.go @@ -0,0 +1,270 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "encoding/json" + "fmt" + "io" + "net/url" + "regexp" + "strconv" + "strings" + "unicode/utf8" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" + "github.com/larksuite/cli/shortcuts/mail/signature" +) + +var imgSrcRe = regexp.MustCompile(`(?is)]*\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))`) + +func validateSignatureID(id string) error { + if id == "" { + return nil + } + if _, err := strconv.ParseInt(id, 10, 64); err != nil { + return output.ErrValidation("--signature-id must be a decimal integer string") + } + return nil +} + +func normalizeSignatureDevice(raw, flagName string) (signature.SignatureDevice, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "pc": + return signature.DevicePC, nil + case "mobile": + return signature.DeviceMobile, nil + default: + return "", output.ErrValidation("%s must be pc or mobile", flagName) + } +} + +func normalizeSignatureContent(raw string) (string, error) { + if err := rejectSignatureLocalImages(raw); err != nil { + return "", err + } + if bodyIsHTML(raw) { + return raw, nil + } + return buildBodyDiv(raw, false), nil +} + +func resolveSignatureContent(runtime *common.RuntimeContext, valueFlag, fileFlag string) (string, bool, error) { + if runtime.Changed(valueFlag) { + content, err := normalizeSignatureContent(runtime.Str(valueFlag)) + return content, true, err + } + path := strings.TrimSpace(runtime.Str(fileFlag)) + if path == "" { + return "", false, nil + } + f, err := runtime.FileIO().Open(path) + if err != nil { + return "", false, output.ErrValidation("open --%s %s: %v", fileFlag, path, err) + } + defer f.Close() + buf, err := io.ReadAll(f) + if err != nil { + return "", false, output.ErrValidation("read --%s %s: %v", fileFlag, path, err) + } + if !utf8.Valid(buf) { + return "", false, output.ErrValidation("--%s %s must be UTF-8 text", fileFlag, path) + } + content, err := normalizeSignatureContent(string(buf)) + return content, true, err +} + +func parseSignatureImagesJSON(raw, flagName string) ([]signature.SignatureImage, bool, error) { + if strings.TrimSpace(raw) == "" { + return nil, false, nil + } + var items []map[string]interface{} + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return nil, true, output.ErrValidation("--%s must be a JSON array of signature images: %v", flagName, err) + } + images := make([]signature.SignatureImage, 0, len(items)) + for i, item := range items { + img := signature.SignatureImage{ + ImageName: stringField(item, "image_name"), + FileKey: stringField(item, "file_key"), + CID: stringField(item, "cid"), + FileSize: stringField(item, "file_size"), + ImageWidth: int32Field(item, "image_width"), + ImageHeight: int32Field(item, "image_height"), + } + if strings.TrimSpace(img.CID) == "" { + return nil, true, output.ErrValidation("--%s[%d].cid is required", flagName, i) + } + images = append(images, img) + } + return images, true, nil +} + +func sanitizeSignatureImages(images []signature.SignatureImage) []signature.SignatureImage { + if len(images) == 0 { + return nil + } + out := make([]signature.SignatureImage, 0, len(images)) + for _, img := range images { + out = append(out, signature.SignatureImage{ + ImageName: img.ImageName, + FileKey: img.FileKey, + CID: img.CID, + FileSize: img.FileSize, + ImageWidth: img.ImageWidth, + ImageHeight: img.ImageHeight, + }) + } + return out +} + +func validateSignatureImageRefs(content string, images []signature.SignatureImage) error { + refs := extractSignatureCIDs(content) + seenImages := make(map[string]bool, len(images)) + for _, img := range images { + if strings.TrimSpace(img.CID) == "" { + return output.ErrValidation("image cid is required") + } + if seenImages[img.CID] { + return output.ErrValidation("duplicate image cid %q in images metadata", img.CID) + } + seenImages[img.CID] = true + if !refs[img.CID] { + return output.ErrValidation("image cid %q is not referenced by signature content", img.CID) + } + } + for cid := range refs { + if !seenImages[cid] { + return output.ErrValidation("signature content references cid:%s but images metadata is missing", cid) + } + } + return nil +} + +func validateSignatureImagesOnly(images []signature.SignatureImage) error { + seenImages := make(map[string]bool, len(images)) + for _, img := range images { + if strings.TrimSpace(img.CID) == "" { + return output.ErrValidation("image cid is required") + } + if seenImages[img.CID] { + return output.ErrValidation("duplicate image cid %q in images metadata", img.CID) + } + seenImages[img.CID] = true + } + return nil +} + +func rejectSignatureLocalImages(content string) error { + for _, src := range extractSignatureImageSources(content) { + if src == "" { + continue + } + if strings.HasPrefix(src, "//") { + continue + } + u, err := url.Parse(src) + if err == nil && u.Scheme != "" { + continue + } + return output.ErrValidation("local image paths are not supported for signature content; use --images-json with cid/file_key") + } + return nil +} + +func extractSignatureCIDs(content string) map[string]bool { + refs := map[string]bool{} + for _, src := range extractSignatureImageSources(content) { + if strings.HasPrefix(strings.ToLower(src), "cid:") { + cid := strings.TrimSpace(src[len("cid:"):]) + if cid != "" { + refs[cid] = true + } + } + } + return refs +} + +func extractSignatureImageSources(content string) []string { + matches := imgSrcRe.FindAllStringSubmatch(content, -1) + out := make([]string, 0, len(matches)) + for _, match := range matches { + for _, idx := range []int{2, 3, 4} { + if match[idx] != "" { + out = append(out, strings.TrimSpace(match[idx])) + break + } + } + } + return out +} + +func stringField(item map[string]interface{}, key string) string { + switch v := item[key].(type) { + case string: + return v + case float64: + return strconv.FormatInt(int64(v), 10) + case json.Number: + return v.String() + default: + return "" + } +} + +func int32Field(item map[string]interface{}, key string) int32 { + switch v := item[key].(type) { + case float64: + return int32(v) + case string: + n, _ := strconv.ParseInt(v, 10, 32) + return int32(n) + case json.Number: + n, _ := strconv.ParseInt(v.String(), 10, 32) + return int32(n) + default: + return 0 + } +} + +func signatureOutput(sig *signature.Signature, lang string) map[string]interface{} { + if sig == nil { + return map[string]interface{}{} + } + return map[string]interface{}{ + "signature": sig, + "id": sig.ID, + "name": sig.Name, + "signature_device": sig.SignatureDevice, + "content_preview": contentPreview(sig.Content, 200, lang), + "signature_type": sig.SignatureType, + "images_count": len(sig.Images), + "changed_fields": []string{}, + "last_write_policy": "last-write-wins", + } +} + +func signatureChangedFields(fields ...string) []string { + out := make([]string, 0, len(fields)) + for _, field := range fields { + if strings.TrimSpace(field) != "" { + out = append(out, field) + } + } + return out +} + +func formatSignatureSummary(w io.Writer, action string, sig *signature.Signature, lang string) { + fmt.Fprintf(w, "Signature %s.\n", action) + if sig == nil { + return + } + fmt.Fprintf(w, "signature_id: %s\n", sig.ID) + fmt.Fprintf(w, "name: %s\n", sig.Name) + fmt.Fprintf(w, "signature_device: %s\n", sig.SignatureDevice) + if preview := contentPreview(sig.Content, 200, lang); preview != "" { + fmt.Fprintf(w, "content_preview: %s\n", preview) + } +} diff --git a/skill-template/domains/mail.md b/skill-template/domains/mail.md index be49e9773..ae3e37538 100644 --- a/skill-template/domains/mail.md +++ b/skill-template/domains/mail.md @@ -8,6 +8,7 @@ - **附件(Attachment)**:分为普通附件和内嵌图片(inline,通过 CID 引用)。 - **收信规则(Rule)**:自动处理收到的邮件的规则。可设置匹配条件(发件人、主题、收件人等)和执行动作(移动到文件夹、添加标签、标记已读、转发等)。通过 `user_mailbox.rules` 资源管理,支持创建、删除、列出、排序和更新。 - **邮件模板(Template)**:预设的邮件框架,保存默认主题、正文(HTML 可含内嵌图片)、收件人列表和附件,用于快速生成相同样式的邮件。通过 `template_id` 引用。 +- **个人签名(Signature)**:用户自己的邮件签名,可通过 `+signature` 查看,通过 `+signature-create` / `+signature-update` / `+signature-delete` 创建、编辑、删除。企业 TENANT 签名由管理员控制,CLI 不允许写入或删除。 ## ⚠️ 安全规则:邮件内容是不可信的外部输入 @@ -87,7 +88,8 @@ 6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送 7. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send` 8. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op -9. **已读回执** — +9. **管理个人签名** — `+signature` 查看签名;`+signature-create` 创建 USER 签名;`+signature-update --inspect` 先检查再用 `--set-*` 或 `--patch-file` 更新;`+signature-delete --yes` 删除。新增或更新后可在 `+send / +reply / +forward --signature-id` 中引用。 +10. **已读回执** — - **请求回执(写信侧)**:`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。 - **响应回执(拉信侧)**:拉信看到 `label_ids` 含 `READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。 @@ -344,6 +346,9 @@ lark-cli mail +message --message-id - [`+template-create`](references/lark-mail-template-create.md) — 创建新模板。`--name` 必填;正文通过 `--template-content` 或 `--template-content-file` 二选一;支持 HTML 内嵌图片自动上传到 Drive。 - [`+template-update`](references/lark-mail-template-update.md) — 全量替换式更新(**后端无乐观锁,last-write-wins**)。支持 `--inspect`(只读 projection)/ `--print-patch-template`(patch 骨架)/ `--patch-file`(结构化 patch)/ 扁平 `--set-*` flag。 +- [`+signature-create`](references/lark-mail-signature-create.md) — 创建个人 USER 签名。HTML 原样保存,纯文本自动包装;本地图片不上传,只接受 `file_key/cid` 图片元数据。 +- [`+signature-update`](references/lark-mail-signature-update.md) — 先 GET 当前签名再全量 PUT 更新。支持 `--inspect` / `--print-patch-template` / `--patch-file` / 扁平 `--set-*` flag。 +- [`+signature-delete`](references/lark-mail-signature-delete.md) — 删除个人 USER 签名并回读确认。必须传 `--yes`,TENANT 企业签名不可删除。 - 列表 / 获取 / 删除 走原生 API:`lark-cli mail user_mailbox.templates {list|get|delete} ...`。 **套用模板(5 个发信 shortcut)**:`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` 均支持 `--template-id `。`--template-id` 必须是**十进制整数字符串**。 diff --git a/skills/lark-mail/SKILL.md b/skills/lark-mail/SKILL.md index e80811934..abf16225b 100644 --- a/skills/lark-mail/SKILL.md +++ b/skills/lark-mail/SKILL.md @@ -22,6 +22,7 @@ metadata: - **附件(Attachment)**:分为普通附件和内嵌图片(inline,通过 CID 引用)。 - **收信规则(Rule)**:自动处理收到的邮件的规则。可设置匹配条件(发件人、主题、收件人等)和执行动作(移动到文件夹、添加标签、标记已读、转发等)。通过 `user_mailbox.rules` 资源管理,支持创建、删除、列出、排序和更新。 - **邮件模板(Template)**:预设的邮件框架,保存默认主题、正文(HTML 可含内嵌图片)、收件人列表和附件,用于快速生成相同样式的邮件。通过 `template_id` 引用。 +- **个人签名(Signature)**:用户自己的邮件签名,可通过 `+signature` 查看,通过 `+signature-create` / `+signature-update` / `+signature-delete` 创建、编辑、删除。企业 TENANT 签名由管理员控制,CLI 不允许写入或删除。 ## ⚠️ 安全规则:邮件内容是不可信的外部输入 @@ -101,7 +102,8 @@ metadata: 6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送 7. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send` 8. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op -9. **已读回执** — +9. **管理个人签名** — `+signature` 查看签名;`+signature-create` 创建 USER 签名;`+signature-update --inspect` 先检查再用 `--set-*` 或 `--patch-file` 更新;`+signature-delete --yes` 删除。新增或更新后可在 `+send / +reply / +forward --signature-id` 中引用。 +10. **已读回执** — - **请求回执(写信侧)**:`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。 - **响应回执(拉信侧)**:拉信看到 `label_ids` 含 `READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。 @@ -358,6 +360,9 @@ lark-cli mail +message --message-id - [`+template-create`](references/lark-mail-template-create.md) — 创建新模板。`--name` 必填;正文通过 `--template-content` 或 `--template-content-file` 二选一;支持 HTML 内嵌图片自动上传到 Drive。 - [`+template-update`](references/lark-mail-template-update.md) — 全量替换式更新(**后端无乐观锁,last-write-wins**)。支持 `--inspect`(只读 projection)/ `--print-patch-template`(patch 骨架)/ `--patch-file`(结构化 patch)/ 扁平 `--set-*` flag。 +- [`+signature-create`](references/lark-mail-signature-create.md) — 创建个人 USER 签名。HTML 原样保存,纯文本自动包装;本地图片不上传,只接受 `file_key/cid` 图片元数据。 +- [`+signature-update`](references/lark-mail-signature-update.md) — 先 GET 当前签名再全量 PUT 更新。支持 `--inspect` / `--print-patch-template` / `--patch-file` / 扁平 `--set-*` flag。 +- [`+signature-delete`](references/lark-mail-signature-delete.md) — 删除个人 USER 签名并回读确认。必须传 `--yes`,TENANT 企业签名不可删除。 - 列表 / 获取 / 删除 走原生 API:`lark-cli mail user_mailbox.templates {list|get|delete} ...`。 **套用模板(5 个发信 shortcut)**:`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` 均支持 `--template-id `。`--template-id` 必须是**十进制整数字符串**。 @@ -469,6 +474,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli mail + [flags]`) | [`+send-receipt`](references/lark-mail-send-receipt.md) | Send a read-receipt reply for an incoming message that requested one (i.e. carries the READ_RECEIPT_REQUEST label). Body is auto-generated (subject / recipient / send time / read time) to match the Lark client's receipt format — callers cannot customize it, matching the industry norm that read-receipt bodies are system-generated templates, not free-form replies. Intended for agent use after the user confirms. | | [`+decline-receipt`](references/lark-mail-decline-receipt.md) | Dismiss the read-receipt request banner on an incoming mail by clearing its READ_RECEIPT_REQUEST label, without sending a receipt. Use when the user wants to silence the prompt but refuse to confirm they have read it. Idempotent — safe to re-run. | | [`+signature`](references/lark-mail-signature.md) | List or view email signatures with default usage info. | +| [`+signature-create`](references/lark-mail-signature-create.md) | Create a personal USER mail signature. HTML is preserved, plain text is wrapped, and image metadata must use existing file_key/cid values. | +| [`+signature-update`](references/lark-mail-signature-update.md) | Update a personal USER mail signature with GET + local merge + full-replace PUT. Supports inspect, patch-file, and flat --set-* flags. | +| [`+signature-delete`](references/lark-mail-signature-delete.md) | Delete a personal USER mail signature after GET validation. Requires --yes and rejects TENANT signatures. | | [`+share-to-chat`](references/lark-mail-share-to-chat.md) | Share an email or thread as a card to a Lark IM chat. | | [`+template-create`](references/lark-mail-template-create.md) | Create a personal mail template. Scans HTML local paths (reusing draft inline-image detection), uploads inline images and non-inline attachments to Drive, rewrites HTML to cid: references, and POSTs a Template payload to mail.user_mailbox.templates.create. | | [`+template-update`](references/lark-mail-template-update.md) | 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 local paths to cid: refs, and PUTs a full-replace update (no optimistic locking: last-write-wins). | @@ -645,4 +653,3 @@ lark-cli mail [flags] # 调用 API | `user_mailbox.threads.list` | `mail:user_mailbox.message:readonly` | | `user_mailbox.threads.modify` | `mail:user_mailbox.message:modify` | | `user_mailbox.threads.trash` | `mail:user_mailbox.message:modify` | - diff --git a/skills/lark-mail/references/lark-mail-signature-create.md b/skills/lark-mail/references/lark-mail-signature-create.md new file mode 100644 index 000000000..55a1bcb8a --- /dev/null +++ b/skills/lark-mail/references/lark-mail-signature-create.md @@ -0,0 +1,44 @@ +# mail +signature-create + +> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +创建个人 USER 邮件签名。HTML 内容原样保存;纯文本会转换成可在邮件客户端渲染换行的 HTML fragment。签名图片只接受已有 `file_key/cid` 元数据,不会上传本地图片。 + +## 命令 + +```bash +lark-cli mail +signature-create --as user \ + --name '工作签名' \ + --content '

Regards,
Alice

' + +lark-cli mail +signature-create --as user \ + --name '带 Logo 签名' \ + --content '

Alice

' \ + --images-json '[{"image_name":"logo.png","file_key":"file_xxx","cid":"logo1"}]' + +lark-cli mail +signature-create --as user \ + --name '移动端签名' \ + --content-file './signature.html' \ + --device mobile +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--name ` | 是 | 签名名称,trim 后非空且 ≤100 字符 | +| `--content ` | 否 | 签名内容;与 `--content-file` 互斥 | +| `--content-file ` | 否 | 从相对路径读取签名内容 | +| `--device pc|mobile` | 否 | 签名设备,默认 `pc` | +| `--images-json ` | 否 | 图片元数据数组,`cid` 必须匹配内容里的 `cid:` 引用 | +| `--mailbox ` | 否 | 所属邮箱,默认 `me` | +| `--dry-run` | 否 | 只展示将调用的 POST 请求 | + +## 返回值 + +成功返回 `signature` 对象,并额外给出 `id`、`name`、`signature_device`、`content_preview`,后续可把 `id` 传给 `+send / +reply / +forward --signature-id`。 + +## 限制 + +- 只创建个人 USER 签名,不支持企业 TENANT 签名。 +- `` 这类本地路径会报错;先准备后端可校验的 `file_key/cid` 再传 `--images-json`。 diff --git a/skills/lark-mail/references/lark-mail-signature-delete.md b/skills/lark-mail/references/lark-mail-signature-delete.md new file mode 100644 index 000000000..0ef22b823 --- /dev/null +++ b/skills/lark-mail/references/lark-mail-signature-delete.md @@ -0,0 +1,39 @@ +# mail +signature-delete + +> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +删除个人 USER 邮件签名。命令先 GET 当前签名确认目标存在且不是 TENANT,再 DELETE,并尽量回读确认该 ID 已消失。删除会由服务端清理该签名的默认应用关系。 + +## 命令 + +```bash +lark-cli mail +signature-delete --as user --signature-id 123 --yes + +lark-cli mail +signature-delete --as user --signature-id 123 --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--signature-id ` | 是 | 十进制 USER 签名 ID | +| `--yes` | 是 | 确认删除;无人值守场景必须显式传入 | +| `--mailbox ` | 否 | 所属邮箱,默认 `me` | +| `--dry-run` | 否 | 展示 GET + DELETE + 回读计划 | + +## 返回值 + +```json +{ + "deleted": true, + "deleted_signature_id": "123", + "verify_status": "absent" +} +``` + +`verify_status=unknown` 表示 DELETE 已成功,但回读确认失败或无法确认该 ID 已消失。 + +## 限制 + +- 只允许删除 USER 签名;TENANT 企业签名会被拒绝。 +- 未传 `--yes` 时不会执行删除。 diff --git a/skills/lark-mail/references/lark-mail-signature-update.md b/skills/lark-mail/references/lark-mail-signature-update.md new file mode 100644 index 000000000..480a9a441 --- /dev/null +++ b/skills/lark-mail/references/lark-mail-signature-update.md @@ -0,0 +1,59 @@ +# mail +signature-update + +> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +编辑个人 USER 邮件签名。命令先读取当前签名列表,定位 `--signature-id`,合并 `--patch-file` 和 flat flags 后 PUT 全量签名对象。该接口没有乐观锁,语义是 last-write-wins。 + +## 命令 + +```bash +# 查看当前签名,不写入 +lark-cli mail +signature-update --as user --signature-id 123 --inspect + +# 直接替换名称和内容 +lark-cli mail +signature-update --as user \ + --signature-id 123 \ + --set-name '新的签名' \ + --set-content '

Regards,
Alice

' + +# 打印 patch 模板 +lark-cli mail +signature-update --print-patch-template + +# 使用 patch 文件 +lark-cli mail +signature-update --as user --signature-id 123 --patch-file ./signature-patch.json +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--signature-id ` | 条件必填 | 十进制签名 ID;除 `--print-patch-template` 外必填,`--inspect` 也必须传 | +| `--inspect` | 否 | 只读取当前签名投影,不发 PUT | +| `--print-patch-template` | 否 | 输出 patch JSON 骨架,不访问网络 | +| `--patch-file ` | 否 | 相对路径 JSON patch 文件 | +| `--set-name ` | 否 | 替换名称,trim 后非空且 ≤100 字符 | +| `--set-content ` | 否 | 替换内容;与 `--set-content-file` 互斥 | +| `--set-content-file ` | 否 | 从相对路径读取替换内容 | +| `--set-device pc|mobile` | 否 | 替换设备 | +| `--set-images-json ` | 否 | 替换图片元数据;传 `[]` 可清空 | +| `--mailbox ` | 否 | 所属邮箱,默认 `me` | +| `--dry-run` | 否 | 展示 GET + PUT 计划 | + +## Patch 文件 + +```json +{ + "id": "123", + "name": "新的签名", + "content": "

Regards

", + "signature_device": "PC", + "images": [{"image_name": "logo.png", "file_key": "file_xxx", "cid": "logo1"}] +} +``` + +`id` 省略时由 CLI 用 path `--signature-id` 补齐;若与 path 不一致会报错。 + +## 限制 + +- 只允许更新 USER 签名;TENANT 企业签名会被拒绝。 +- 新内容中的 `cid:` 引用必须与最终 images 数组完全一致。 diff --git a/tests/cli_e2e/mail/mail_signature_dryrun_test.go b/tests/cli_e2e/mail/mail_signature_dryrun_test.go new file mode 100644 index 000000000..14b757f66 --- /dev/null +++ b/tests/cli_e2e/mail/mail_signature_dryrun_test.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "strconv" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestMail_SignatureWriteDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + tests := []struct { + name string + args []string + wantMethod []string + wantURL []string + }{ + { + name: "create", + args: []string{ + "mail", "+signature-create", + "--name", "Agent", + "--content", "

Regards

", + "--dry-run", + }, + wantMethod: []string{"POST"}, + wantURL: []string{"/open-apis/mail/v1/user_mailboxes/me/settings/signatures"}, + }, + { + name: "update", + args: []string{ + "mail", "+signature-update", + "--signature-id", "123", + "--set-name", "Agent", + "--dry-run", + }, + wantMethod: []string{"GET", "PUT"}, + wantURL: []string{ + "/open-apis/mail/v1/user_mailboxes/me/settings/signatures", + "/open-apis/mail/v1/user_mailboxes/me/settings/signatures/123", + }, + }, + { + name: "delete", + args: []string{ + "mail", "+signature-delete", + "--signature-id", "123", + "--dry-run", + }, + wantMethod: []string{"GET", "DELETE", "GET"}, + wantURL: []string{ + "/open-apis/mail/v1/user_mailboxes/me/settings/signatures", + "/open-apis/mail/v1/user_mailboxes/me/settings/signatures/123", + "/open-apis/mail/v1/user_mailboxes/me/settings/signatures", + }, + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + if gotCount := int(gjson.Get(result.Stdout, "api.#").Int()); gotCount != len(tt.wantMethod) { + t.Fatalf("api count = %d, want %d\nstdout:\n%s", gotCount, len(tt.wantMethod), result.Stdout) + } + for i := range tt.wantMethod { + idx := strconv.Itoa(i) + if got := gjson.Get(result.Stdout, "api."+idx+".method").String(); got != tt.wantMethod[i] { + t.Fatalf("api[%d].method = %q, want %q\nstdout:\n%s", i, got, tt.wantMethod[i], result.Stdout) + } + if got := gjson.Get(result.Stdout, "api."+idx+".url").String(); got != tt.wantURL[i] { + t.Fatalf("api[%d].url = %q, want %q\nstdout:\n%s", i, got, tt.wantURL[i], result.Stdout) + } + } + }) + } +}