Compare commits

...

19 Commits

Author SHA1 Message Date
anguohui
d2e10b39a3 chore: add global PPE headers for testing (x-use-ppe, x-tt-env) 2026-07-08 22:03:52 +08:00
anguohui
6d3275f35d feat(apps): add modern_html enum and pass --app-type instead of --template to miaoda-cli 2026-07-08 16:42:57 +08:00
anguohui
994ba94aba chore(apps): pin miaoda-cli to alpha version 0.1.20-alpha.dd573f8 2026-07-08 15:31:09 +08:00
anguohui
f60ff95c22 test(apps): improve coverage for sync field, queryAppType, and scaffoldInitArgs 2026-07-08 14:34:58 +08:00
anguohui
577c4df634 style(apps): fix gofmt formatting in apps_init.go 2026-07-08 14:25:17 +08:00
anguohui
a0bdde46b0 refactor(apps): remove --template flag from +init, derive template from queryAppType with full_stack fallback 2026-07-08 12:40:27 +08:00
anguohui
b16f1d34f3 feat(apps): surface sync field in +release-create response 2026-07-08 12:12:59 +08:00
anguohui
fc1aa02aa8 test(apps): add full_stack scaffold test case 2026-07-07 23:14:22 +08:00
anguohui
92bed1b424 refactor(apps): use appInfo struct to parse GET /apps/{id} response 2026-07-07 23:14:22 +08:00
anguohui
7e794a19b5 fix(apps): align queryAppType with actual API path and response structure 2026-07-07 23:14:22 +08:00
anguohui
5142fd0c65 feat(apps): add --source-path flag to +init for existing source file incorporation 2026-07-07 23:14:22 +08:00
anguohui
3bff980d7a refactor(apps): simplify to source_agent in +create, unified scaffold in +init, revert html-publish changes 2026-07-07 23:14:22 +08:00
anguohui
b1819e63b2 refactor(apps): replace appMeta struct with queryAppType string for simpler routing 2026-07-07 23:14:22 +08:00
anguohui
e3ba34df8f feat(apps): add TOS upload path for arch_type=4 html in +html-publish with arch_type-based routing 2026-07-07 23:14:22 +08:00
anguohui
fe4d55ee88 feat(apps): add zip packaging for arch_type=4 html publish path 2026-07-07 23:14:22 +08:00
anguohui
800eeaf50e feat(apps): skip scaffold for legacy html apps, pass app_type/arch_type for arch_type=4 html in +init 2026-07-07 23:14:22 +08:00
anguohui
19e91ec7b0 feat(apps): add queryAppMeta shared function for app_type/arch_type lookup 2026-07-07 23:14:22 +08:00
anguohui
e1e393146a feat(apps): read LARKSUITE_CLI_AGENT env var and pass app_source in +create 2026-07-07 23:14:22 +08:00
bubbmon233
f495cbb166 feat(mail): add message modify and trash shortcuts (#1567) 2026-07-07 21:44:40 +08:00
27 changed files with 1709 additions and 130 deletions

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"os"
"strings"
"github.com/larksuite/cli/shortcuts/common"
@@ -29,7 +30,7 @@ var AppsCreate = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "name", Desc: "app display name", Required: true},
{Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "full_stack"}},
{Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "full_stack", "modern_html"}},
{Name: "description", Desc: "app description"},
{Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"},
},
@@ -71,5 +72,8 @@ func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
if icon := strings.TrimSpace(rctx.Str("icon-url")); icon != "" {
body["icon_url"] = icon
}
if agent := os.Getenv("LARKSUITE_CLI_AGENT"); agent != "" {
body["source_agent"] = agent
}
return body
}

View File

@@ -273,3 +273,92 @@ func TestAppsCreate_FullstackDryRun(t *testing.T) {
t.Fatalf("dry-run should not contain message: %s", got)
}
}
func TestAppsCreate_WithAgentEnvVar(t *testing.T) {
t.Setenv("LARKSUITE_CLI_AGENT", "doubao")
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["source_agent"] != "doubao" {
t.Fatalf("body.source_agent = %v, want doubao", sent["source_agent"])
}
}
func TestAppsCreate_WithoutAgentEnvVar(t *testing.T) {
t.Setenv("LARKSUITE_CLI_AGENT", "")
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if _, present := sent["source_agent"]; present {
t.Fatalf("source_agent should not be present when env var is empty: %v", sent)
}
}
func TestAppsCreate_AgentEnvVarNotSet(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if _, present := sent["source_agent"]; present {
t.Fatalf("source_agent should not be present when env var is unset: %v", sent)
}
}

View File

@@ -11,6 +11,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"unicode"
@@ -38,8 +39,7 @@ const (
)
const (
miaodaCLIPkg = "@lark-apaas/miaoda-cli@latest"
defaultTemplate = "nestjs-react-fullstack"
miaodaCLIPkg = "@lark-apaas/miaoda-cli@0.1.20-alpha.dd573f8"
metaRelPath = ".spark/meta.json"
steeringRelPath = ".agent/skills/steering"
seedReadme = "README.md"
@@ -75,7 +75,7 @@ var AppsInit = common.Shortcut{
// check lives in Validate (typed validation error -> exit 2).
{Name: "app-id", Desc: "app ID"},
{Name: "dir", Desc: "clone target directory; absolute or relative path (default ./<app-id>)"},
{Name: "template", Desc: "code-init template for an empty repo; optional — if omitted, derived from the app's tech stack"},
{Name: "source-path", Desc: "path to existing source files (e.g. HTML output from an agent) to incorporate into the initialized project"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
@@ -85,14 +85,13 @@ var AppsInit = common.Shortcut{
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
template := resolveTemplate(rctx, appID)
dry := common.NewDryRunAPI().
Desc("Initialize app code (credential-init, clone, checkout, npx code-init, optional commit/push)").
Set("credential_init", fmt.Sprintf("apps +git-credential-init --app-id %s --format json", appID)).
Set("checkout", "git checkout "+defaultInitBranch).
Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --template %s --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, template, appID, miaodaCLIPkg)).
Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --app-type <appType> --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, appID, miaodaCLIPkg)).
Set("commit_push", "conditional: git add -A + commit + push origin "+defaultInitBranch+" when the working tree has changes").
Set("template", template).
Set("template", "derived from queryAppType (fallback: full_stack)").
Set("env_pull", fmt.Sprintf("apps +env-pull --app-id %s --project-path <clone_path> --format json (after successful init)", appID))
dir, err := resolveTargetPath(rctx, appID)
if err != nil {
@@ -122,20 +121,6 @@ func defaultCloneDir(appID string) string {
return filepath.Join(".", appID)
}
// resolveTemplate returns the scaffold template for an empty-repo `app init`.
// An explicit --template wins. When omitted, it should be derived from the
// app's tech stack.
// TODO(apps-init): look up the app by appID via the apps API (e.g. `apps +list`
// or a get-app endpoint), read its tech stack, and map tech-stack -> template
// through a (future) enum. Until that lands, fall back to defaultTemplate.
func resolveTemplate(rctx *common.RuntimeContext, appID string) string {
if t := strings.TrimSpace(rctx.Str("template")); t != "" {
return t
}
// TODO(apps-init): derive from app tech stack (apps API + enum mapping).
return defaultTemplate
}
// initLogf writes a one-line progress message to stderr. stdout stays reserved
// for the structured JSON envelope, so progress never pollutes it. Callers must
// never pass a raw repository_url (it may embed a token) — pass step names,
@@ -326,16 +311,14 @@ func isEmptyRepo(ctx context.Context, dir string) (bool, error) {
// runScaffold runs the npx scaffolding step inside the cloned repo (cwd=dir).
// Empty repo -> `app init`; non-empty -> `app sync` + meta app_id patch +
// conditional `skills sync`. Returns "init" or "upgrade".
func runScaffold(ctx context.Context, dir, appID, template string) (string, error) {
func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (string, error) {
empty, err := isEmptyRepo(ctx, dir)
if err != nil {
return "", err
}
if empty {
// isEmptyRepo treats a repo with no tracked files — or only the backend's
// seed README.md — as empty. If other seed files (e.g. .gitignore) can
// appear, extend isEmptyRepo's allow-list accordingly.
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", template, "--app-id", appID); err != nil {
args := scaffoldInitArgs(appType, appID, sourcePath)
if _, stderr, err := initRunner.Run(ctx, dir, "npx", args...); err != nil {
return "", appsExternalToolError(err, "npx app init failed: %s", gitErr(stderr, err))
}
return scaffoldKindInit, nil
@@ -354,6 +337,22 @@ func runScaffold(ctx context.Context, dir, appID, template string) (string, erro
return scaffoldKindUpgrade, nil
}
// scaffoldInitArgs builds the npx argument list for `app init`.
// appType from queryAppType is passed as --app-type; falls back to "full_stack"
// when empty. sourcePath is appended as --source-path when non-empty.
func scaffoldInitArgs(appType, appID, sourcePath string) []string {
base := []string{"-y", "--prefer-online", miaodaCLIPkg, "app", "init"}
at := appType
if at == "" {
at = "full_stack"
}
base = append(base, "--app-type", at, "--app-id", appID)
if sourcePath != "" {
base = append(base, "--source-path", sourcePath)
}
return base
}
// parseRepoURLFromEnvelope extracts data.repository_url from a lark-cli JSON
// envelope ({"ok":true,"data":{"repository_url":"..."}}). The field name
// matches the contract emitted by `apps +git-credential-init`.
@@ -445,6 +444,8 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
return err
}
appType := queryAppType(ctx, rctx, appID)
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
// initialized app repo -> skip clone/scaffold/commit, but still refresh
// the local env so a re-run picks up the latest startup env vars.
@@ -457,6 +458,9 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
"committed": false,
"pushed": false,
}
if appType != "" {
out["app_type"] = appType
}
initLogf(rctx, "Pulling local environment variables...")
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
envPulled := envPullErr == ""
@@ -515,7 +519,8 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
}
initLogf(rctx, "Initializing app code (running miaoda-cli)...")
scaffold, err := runScaffold(ctx, dir, appID, resolveTemplate(rctx, appID))
sourcePath := strings.TrimSpace(rctx.Str("source-path"))
scaffold, err := runScaffold(ctx, dir, appID, appType, sourcePath)
if err != nil {
return err
}
@@ -530,15 +535,6 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
initLogf(rctx, "Working tree clean — skipped commit/push")
}
initLogf(rctx, "Pulling local environment variables...")
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
envPulled := envPullErr == ""
if envPulled {
initLogf(rctx, "Local environment written to %s", envFile)
} else {
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
}
out := map[string]interface{}{
"app_id": appID,
"repository_url": redactURLCredentials(repoURL),
@@ -547,15 +543,25 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
"scaffold": scaffold,
"committed": committed,
"pushed": pushed,
"env_pulled": envPulled,
"message": "Repository initialized. You can start developing.",
}
if appType != "" {
out["app_type"] = appType
}
initLogf(rctx, "Pulling local environment variables...")
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
envPulled := envPullErr == ""
out["env_pulled"] = envPulled
if envPulled {
initLogf(rctx, "Local environment written to %s", envFile)
out["env_file"] = envFile
} else {
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
out["env_pull_error"] = envPullErr
out["message"] = fmt.Sprintf("Repository initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID)
}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Repository initialized at %s\n", dir)
fmt.Fprintf(w, " branch: %s\n scaffold: %s\n", defaultInitBranch, scaffold)

View File

@@ -20,46 +20,20 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// testRuntimeWithDir builds a *common.RuntimeContext whose backing cobra command
// has string flags "dir" (=dirFlag) and "template" (=defaultTemplate) registered,
// mirroring how +init reads them at runtime via rctx.Str.
// has a string flag "dir" (=dirFlag) registered, mirroring how +init reads it
// at runtime via rctx.Str.
func testRuntimeWithDir(t *testing.T, dirFlag string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "init"}
cmd.Flags().String("dir", dirFlag, "")
cmd.Flags().String("template", defaultTemplate, "")
return common.TestNewRuntimeContext(cmd, nil)
}
// testRuntimeWithTemplate builds a *common.RuntimeContext with "dir" and
// "template" string flags registered, mirroring +init's runtime flag set. The
// template flag is registered with an empty default (matching the real flag,
// which no longer carries Default: defaultTemplate); pass tpl="" to model an
// omitted --template and a non-empty tpl to model an explicit one.
func testRuntimeWithTemplate(t *testing.T, dirFlag, tpl string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "init"}
cmd.Flags().String("dir", dirFlag, "")
cmd.Flags().String("template", tpl, "")
return common.TestNewRuntimeContext(cmd, nil)
}
func TestResolveTemplate(t *testing.T) {
if got := resolveTemplate(testRuntimeWithTemplate(t, "", "foo"), "app_x"); got != "foo" {
t.Errorf("explicit --template = %q, want foo", got)
}
if got := resolveTemplate(testRuntimeWithTemplate(t, "", ""), "app_x"); got != defaultTemplate {
t.Errorf("omitted --template = %q, want fallback %q", got, defaultTemplate)
}
// Whitespace-only --template is treated as omitted -> fallback.
if got := resolveTemplate(testRuntimeWithTemplate(t, "", " "), "app_x"); got != defaultTemplate {
t.Errorf("whitespace --template = %q, want fallback %q", got, defaultTemplate)
}
}
func TestResolveTargetPath(t *testing.T) {
got, err := resolveTargetPath(testRuntimeWithDir(t, ""), "app_x")
if err != nil {
@@ -261,12 +235,12 @@ func TestRunScaffold_EmptyRepo(t *testing.T) {
t.Run("ls="+ls, func(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ls}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack")
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "")
if err != nil || kind != "init" {
t.Fatalf("ls=%q kind=%q err=%v, want init", ls, kind, err)
}
c := findCall(f.calls, "npx", "-y")
if c == nil || !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", "nestjs-react-fullstack", "--app-id", "app_x") {
if c == nil || !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--app-type", "full_stack", "--app-id", "app_x") {
t.Errorf("app init not invoked with expected args: %v", f.calls)
}
if c != nil && containsAll(c, "--local") {
@@ -280,7 +254,7 @@ func TestRunScaffold_NonEmpty_SyncsWhenNoSteering(t *testing.T) {
dir := t.TempDir() // no steering dir, no meta.json
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), dir, "app_x", "nestjs-react-fullstack")
kind, err := runScaffold(context.Background(), dir, "app_x", "", "")
if err != nil || kind != "upgrade" {
t.Fatalf("kind=%q err=%v, want upgrade", kind, err)
}
@@ -299,7 +273,7 @@ func TestRunScaffold_NonEmpty_SkipsSyncWhenSteeringExists(t *testing.T) {
os.MkdirAll(filepath.Join(dir, steeringRelPath), 0o755)
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}}
withFakeRunner(t, f)
if _, err := runScaffold(context.Background(), dir, "app_x", "nestjs-react-fullstack"); err != nil {
if _, err := runScaffold(context.Background(), dir, "app_x", "", ""); err != nil {
t.Fatal(err)
}
if findCallArg(f.calls, "npx", "skills", "sync") != nil {
@@ -313,7 +287,7 @@ func TestRunScaffold_AppInitFailure(t *testing.T) {
"npx -y": {stderr: "boom", err: errors.New("exit 1")},
}}
withFakeRunner(t, f)
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack"); err == nil {
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", ""); err == nil {
t.Error("app init failure must propagate")
}
}
@@ -342,12 +316,12 @@ func TestAppsInit_EmptyRepo_EndToEnd(t *testing.T) {
if _, ok := data["npx_skipped"]; ok {
t.Error("npx_skipped must be removed")
}
// --template is omitted here, so resolveTemplate falls back to
// defaultTemplate and `app init` must still receive --template nestjs-react-fullstack.
// appType is empty, so scaffoldInitArgs falls back to "full_stack"
// and `app init` must still receive --template full_stack.
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Error("npx scaffold not invoked")
} else if !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", defaultTemplate, "--app-id", "app_x") {
} else if !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--app-type", "full_stack", "--app-id", "app_x") {
t.Errorf("app init missing expected --template fallback args: %v", c)
} else if containsAll(c, "--local") {
t.Errorf("app init must NOT carry --local: %v", c)
@@ -751,22 +725,6 @@ func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buf
}
func TestAppsInit_Req1_Wording(t *testing.T) {
var tmpl *common.Flag
for i := range AppsInit.Flags {
if AppsInit.Flags[i].Name == "template" {
tmpl = &AppsInit.Flags[i]
}
}
if tmpl == nil {
t.Fatal("--template flag missing")
}
if strings.Contains(strings.ToLower(tmpl.Desc), "scaffold") {
t.Errorf("--template Desc still mentions scaffold: %q", tmpl.Desc)
}
if !strings.Contains(strings.ToLower(tmpl.Desc), "code-init") {
t.Errorf("--template Desc should use code-init wording: %q", tmpl.Desc)
}
// The --dry-run output is a flat object (DryRunAPI marshals to top-level keys
// description/scaffold/api/...), NOT wrapped in {"data":...}, so parse stdout
// directly rather than via parseEnvelopeData.
@@ -787,9 +745,8 @@ func TestAppsInit_Req1_Wording(t *testing.T) {
t.Error("dry-run must keep machine-contract key `scaffold`")
} else if !strings.Contains(scaffold, "skills sync --local") {
t.Errorf("dry-run scaffold string must show --local on skills sync: %q", scaffold)
} else if strings.Contains(scaffold, "app init --template nestjs-react-fullstack --app-id app_x --local") ||
strings.Contains(scaffold, "app sync --local") {
t.Errorf("dry-run scaffold string must NOT show --local on app init / app sync: %q", scaffold)
} else if strings.Contains(scaffold, "app sync --local") {
t.Errorf("dry-run scaffold string must NOT show --local on app sync: %q", scaffold)
}
f := &fakeCommandRunner{results: map[string]fakeCallResult{
@@ -1250,7 +1207,7 @@ func TestRunScaffold_NonEmpty_SyncFailure(t *testing.T) {
"git ls-files": {stdout: "src/x.ts\n"},
"npx -y": {err: errors.New("sync boom")},
}})
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "tpl"); err == nil {
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", ""); err == nil {
t.Error("npx app sync failure must surface as an error")
}
}
@@ -1630,7 +1587,7 @@ func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) {
"git ls-files": {stderr: "fatal: not a git repository", err: cause},
}}
withFakeRunner(t, f)
_, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack")
_, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "")
if err == nil {
t.Fatalf("expected error from failing git subprocess")
}
@@ -1645,3 +1602,195 @@ func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) {
t.Fatalf("cause chain not preserved: %v", err)
}
}
func TestRunScaffold_HtmlPassesTemplate(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "html", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if kind != scaffoldKindInit {
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
}
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "html") {
t.Errorf("expected --template html in args: %v", c)
}
}
func TestRunScaffold_ModernHtmlPassesTemplate(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "modern_html", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if kind != scaffoldKindInit {
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
}
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "modern_html") {
t.Errorf("expected --template modern_html in args: %v", c)
}
}
func TestRunScaffold_EmptyAppTypeFallback(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if kind != scaffoldKindInit {
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
}
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "full_stack") {
t.Errorf("expected --template full_stack in args: %v", c)
}
}
func TestRunScaffold_FullStackPassesTemplate(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "full_stack", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if kind != scaffoldKindInit {
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
}
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "full_stack") {
t.Errorf("expected --template full_stack in args: %v", c)
}
}
func TestScaffoldInitArgs_WithAppType(t *testing.T) {
args := scaffoldInitArgs("modern_html", "app_x", "")
if !containsAll(args, "--app-type", "modern_html", "--app-id", "app_x") {
t.Errorf("expected --template modern_html --app-id app_x, got %v", args)
}
for _, a := range args {
if a == "--source-path" {
t.Errorf("--source-path must not appear when sourcePath is empty: %v", args)
}
}
}
func TestScaffoldInitArgs_EmptyFallback(t *testing.T) {
args := scaffoldInitArgs("", "app_x", "")
if !containsAll(args, "--app-type", "full_stack", "--app-id", "app_x") {
t.Errorf("expected --template full_stack fallback, got %v", args)
}
}
func TestScaffoldInitArgs_WithSourcePath(t *testing.T) {
args := scaffoldInitArgs("modern_html", "app_x", "/path/to/src")
if !containsAll(args, "--app-type", "modern_html", "--app-id", "app_x", "--source-path", "/path/to/src") {
t.Errorf("expected --source-path /path/to/src, got %v", args)
}
}
func TestAppsInit_WithAppType_FreshClone(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{
"credential-init": credInitOK("http://u:t@h/app_typed.git"),
"git clone": {},
"git checkout": {},
"git ls-files": {stdout: ""},
"git status": {stdout: " A src/app.ts\n"},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
// Register a meta mock so queryAppType returns "modern_html"
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_typed",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_typed",
"app_type": "MODERN_HTML",
},
},
},
})
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_typed", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := parseEnvelopeData(t, stdout)
if data["app_type"] != "modern_html" {
t.Errorf("app_type = %v, want modern_html", data["app_type"])
}
// Verify the scaffold used --template modern_html
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "modern_html") {
t.Errorf("expected --template modern_html, got %v", c)
}
}
func TestAppsInit_WithAppType_AlreadyInitialized(t *testing.T) {
dir := relCloneDir(t)
abs, err := filepath.Abs(dir)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(abs, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(abs, metaRelPath), []byte(`{"app_id":"app_typed2"}`), 0o644); err != nil {
t.Fatal(err)
}
envFile := filepath.Join(abs, ".env.local")
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(envFile)}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
// Register meta mock so queryAppType returns "html"
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_typed2",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_typed2",
"app_type": "HTML",
},
},
},
})
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_typed2", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := parseEnvelopeData(t, stdout)
if data["scaffold"] != "already_initialized" {
t.Errorf("scaffold = %v, want already_initialized", data["scaffold"])
}
if data["app_type"] != "html" {
t.Errorf("app_type = %v, want html", data["app_type"])
}
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// appInfo represents the app object returned by GET /open-apis/spark/v1/apps/{appID}.
type appInfo struct {
AppID string `json:"app_id"`
AppType string `json:"app_type"`
Name string `json:"name"`
Description string `json:"description"`
IconURL string `json:"icon_url"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
IsPublished bool `json:"is_published"`
}
// queryAppType fetches the app's type string from the server via
// GET /open-apis/spark/v1/apps/{appID}. The server returns uppercase
// values ("HTML", "FULL_STACK", "MODERN_HTML"); this function normalizes
// to lowercase. Returns "" when the API is unavailable or returns an
// error — callers fall back to legacy behavior.
func queryAppType(ctx context.Context, rctx *common.RuntimeContext, appID string) string {
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPITyped("GET", path, nil, nil)
if err != nil {
return ""
}
appRaw, _ := data["app"].(map[string]interface{})
if appRaw == nil {
return ""
}
b, err := json.Marshal(appRaw)
if err != nil {
return ""
}
var info appInfo
if err := json.Unmarshal(b, &info); err != nil {
return ""
}
return strings.ToLower(info.AppType)
}

View File

@@ -0,0 +1,148 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func newMetaTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_meta_test"}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
rt := common.TestNewRuntimeContextForAPI(
context.Background(),
&cobra.Command{Use: "+meta-test"},
cfg, f, core.AsUser,
)
return rt, reg
}
func TestQueryAppType_Success(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_test",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_test",
"app_type": "MODERN_HTML",
},
},
},
})
result := queryAppType(context.Background(), rt, "app_test")
if result != "modern_html" {
t.Errorf("queryAppType = %q, want modern_html", result)
}
}
func TestQueryAppType_FullStack(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_fs",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_fs",
"app_type": "FULL_STACK",
},
},
},
})
result := queryAppType(context.Background(), rt, "app_fs")
if result != "full_stack" {
t.Errorf("queryAppType = %q, want full_stack", result)
}
}
func TestQueryAppType_Html(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_html",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_html",
"app_type": "HTML",
},
},
},
})
result := queryAppType(context.Background(), rt, "app_html")
if result != "html" {
t.Errorf("queryAppType = %q, want html", result)
}
}
func TestQueryAppType_APIError(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_bad",
Status: 500,
Body: map[string]interface{}{"code": float64(99999), "msg": "internal error"},
})
result := queryAppType(context.Background(), rt, "app_bad")
if result != "" {
t.Errorf("queryAppType = %q, want empty on error", result)
}
}
func TestQueryAppType_MissingAppObject(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_no",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{},
},
})
result := queryAppType(context.Background(), rt, "app_no")
if result != "" {
t.Errorf("queryAppType = %q, want empty when app object missing", result)
}
}
func TestQueryAppType_EmptyAppType(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_empty",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_empty",
"app_type": "",
},
},
},
})
result := queryAppType(context.Background(), rt, "app_empty")
if result != "" {
t.Errorf("queryAppType = %q, want empty when app_type is empty", result)
}
}

View File

@@ -56,9 +56,10 @@ var AppsReleaseCreate = common.Shortcut{
out := map[string]interface{}{
"release_id": common.GetString(data, "release_id"),
"status": common.GetString(data, "status"),
"sync": common.GetBool(data, "sync"),
}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "release_id: %s\nstatus: %s\n", out["release_id"], out["status"])
fmt.Fprintf(w, "release_id: %s\nstatus: %s\nsync: %v\n", out["release_id"], out["status"], out["sync"])
})
return nil
},

View File

@@ -105,3 +105,45 @@ func TestAppsReleaseCreateExecute_Success(t *testing.T) {
t.Errorf("status = %v, want publishing", env.Data["status"])
}
}
func TestAppsReleaseCreate_SyncField(t *testing.T) {
rctx, stdoutBuf, reg := newReleaseCreateRuntimeContext(t, "app_sync", "main")
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_sync/releases",
Body: map[string]interface{}{
"code": 0,
"msg": "",
"data": map[string]interface{}{
"release_id": "456",
"status": "publishing",
"sync": true,
},
},
})
err := AppsReleaseCreate.Execute(context.Background(), rctx)
if err != nil {
t.Fatalf("Execute() = %v", err)
}
var env struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
t.Fatalf("unmarshal output: %v\nraw: %s", err, stdoutBuf.String())
}
if !env.OK {
t.Fatalf("expected ok=true, got: %s", stdoutBuf.String())
}
if env.Data["release_id"] != "456" {
t.Errorf("release_id = %v, want 456", env.Data["release_id"])
}
if env.Data["status"] != "publishing" {
t.Errorf("status = %v, want publishing", env.Data["status"])
}
if env.Data["sync"] != true {
t.Errorf("sync = %v, want true", env.Data["sync"])
}
}

View File

@@ -431,6 +431,11 @@ func (ctx *RuntimeContext) buildRequest(method, url string, params map[string]in
if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil {
req.ExtraOpts = append(req.ExtraOpts, optFn)
}
// TODO: remove PPE headers once testing is complete and promoted to production.
ppeHeaders := http.Header{}
ppeHeaders.Set("x-use-ppe", "1")
ppeHeaders.Set("x-tt-env", "ppe_miaoda_lark_cli")
req.ExtraOpts = append(req.ExtraOpts, larkcore.WithHeaders(ppeHeaders))
return req
}

View File

@@ -51,9 +51,8 @@ func hintSendDraft(runtime *common.RuntimeContext, mailboxID, draftID string) {
// original message as read after a reply/reply-all/forward operation.
func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) {
fmt.Fprintf(runtime.IO().ErrOut,
"tip: mark original as read? lark-cli mail user_mailbox.messages batch_modify_message"+
` --params '{"user_mailbox_id":"%s"}' --data '{"message_ids":["%s"],"remove_label_ids":["UNREAD"]}'`+"\n",
sanitizeForTerminal(mailboxID), sanitizeForTerminal(originalMessageID))
"tip: mark original as read? lark-cli mail +message-modify --mailbox '%s' --message-ids '%s' --remove-label-ids UNREAD\n",
shellQuoteForHint(mailboxID), shellQuoteForHint(originalMessageID))
}
// hintReadReceiptRequest prints a stderr tip when a message that the caller

View File

@@ -465,14 +465,19 @@ func TestPrintWatchOutputSchema(t *testing.T) {
// TestHintMarkAsRead verifies hint mark as read.
func TestHintMarkAsRead(t *testing.T) {
rt, _, stderr := newOutputRuntime(t)
// Inject ANSI escape + message ID to verify sanitization
hintMarkAsRead(rt, "me", "msg-\x1b[31m123")
hintMarkAsRead(rt, "mail box;$(whoami)", "msg-\x1b[31m123 'quoted'\nnext")
out := stderr.String()
if strings.Contains(out, "\x1b[") {
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out)
}
if !strings.Contains(out, "msg-123") {
t.Errorf("hintMarkAsRead should contain sanitized message ID, got: %q", out)
if strings.Contains(out, "\nnext") {
t.Errorf("hintMarkAsRead should strip embedded newlines, got: %q", out)
}
if !strings.Contains(out, "--mailbox 'mail box;$(whoami)'") {
t.Errorf("hintMarkAsRead should quote mailbox for shell copy/paste, got: %q", out)
}
if !strings.Contains(out, "--message-ids 'msg-123 '\\''quoted'\\''next'") {
t.Errorf("hintMarkAsRead should quote message ID for shell copy/paste, got: %q", out)
}
}

View File

@@ -0,0 +1,482 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
func messageManageID(suffix string) string {
return "msg_abcdefghijklmnop_" + suffix
}
func stubMessageManagePost(reg *httpmock.Registry, endpoint string, body map[string]interface{}) *httpmock.Stub {
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/" + endpoint,
Body: body,
}
reg.Register(stub)
return stub
}
func decodeMessageManageSummary(t *testing.T, data map[string]interface{}) ([]interface{}, []interface{}) {
t.Helper()
success, ok := data["success_message_ids"].([]interface{})
if !ok {
t.Fatalf("success_message_ids = %#v, want array", data["success_message_ids"])
}
failed, ok := data["failed_message_ids"].([]interface{})
if !ok {
t.Fatalf("failed_message_ids = %#v, want array", data["failed_message_ids"])
}
return success, failed
}
func requireMessageManageValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
t.Helper()
if err == nil {
t.Fatalf("expected validation error for %s, got nil", param)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError for %s, got %T", param, err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed Problem for %s, got %T", param, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
if validationErr.Param != param {
t.Fatalf("param = %q, want %q", validationErr.Param, param)
}
return validationErr
}
func requireMessageManageFailedPrecondition(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected failed precondition error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed Problem, got %T", err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("problem = %s/%s, want validation/failed_precondition", problem.Category, problem.Subtype)
}
}
func TestMessageManage_NormalizeMessageIDs(t *testing.T) {
id1 := messageManageID("1")
id2 := messageManageID("2")
got, err := normalizeMessageManageIDs([]string{id1, id2, id1})
if err != nil {
t.Fatalf("normalizeMessageManageIDs returned error: %v", err)
}
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
t.Fatalf("ids = %v, want [%s %s]", got, id1, id2)
}
got, err = normalizeMessageManageIDs([]string{id1 + "," + id2, id1})
if err != nil {
t.Fatalf("normalizeMessageManageIDs CSV/repeated returned error: %v", err)
}
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
t.Fatalf("CSV/repeated ids = %v, want [%s %s]", got, id1, id2)
}
cases := [][]string{
{""},
{" id_with_leading_space_12345"},
{"msg_abcdefghijklmnop_1,msg_abcdefghijklmnop_2 "},
{"1234567890123456"},
{"short"},
{"msg_abcdefghijklmnop!"},
{"msg_abcdefghijklmnop\t"},
{"msg_abcdefghijklmnop_1\nmsg_abcdefghijklmnop_2"},
{"msg_abcdefghijklmnop_1", "msg_abcdefghijklmnop_2 "},
}
for _, tc := range cases {
_, err := normalizeMessageManageIDs(tc)
requireMessageManageValidationParam(t, err, "--message-ids")
}
}
func TestMessageModify_Metadata(t *testing.T) {
if MailMessageModify.Command != "+message-modify" {
t.Fatalf("Command = %q", MailMessageModify.Command)
}
if MailMessageModify.Risk != "write" {
t.Errorf("Risk = %q, want write", MailMessageModify.Risk)
}
if len(MailMessageModify.AuthTypes) != 1 || MailMessageModify.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v, want [user]", MailMessageModify.AuthTypes)
}
requiredScopes := map[string]bool{
"mail:user_mailbox.message:modify": true,
}
for _, scope := range MailMessageModify.Scopes {
delete(requiredScopes, scope)
}
if len(requiredScopes) != 0 {
t.Errorf("Scopes missing %v", requiredScopes)
}
if len(MailMessageModify.ConditionalScopes) != 1 || MailMessageModify.ConditionalScopes[0] != "mail:user_mailbox.folder:read" {
t.Errorf("ConditionalScopes = %v, want [mail:user_mailbox.folder:read]", MailMessageModify.ConditionalScopes)
}
flags := map[string]common.Flag{}
for _, fl := range MailMessageModify.Flags {
flags[fl.Name] = fl
}
for _, name := range []string{"mailbox", "message-ids", "add-label-ids", "remove-label-ids", "add-folder"} {
if _, ok := flags[name]; !ok {
t.Fatalf("missing --%s flag", name)
}
}
if flags["message-ids"].Type != "string_array" || !flags["message-ids"].Required {
t.Errorf("--message-ids = %#v, want required string_array", flags["message-ids"])
}
}
func TestMessageTrash_Metadata(t *testing.T) {
if MailMessageTrash.Command != "+message-trash" {
t.Fatalf("Command = %q", MailMessageTrash.Command)
}
if MailMessageTrash.Risk != "high-risk-write" {
t.Errorf("Risk = %q, want high-risk-write", MailMessageTrash.Risk)
}
if len(MailMessageTrash.AuthTypes) != 1 || MailMessageTrash.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v, want [user]", MailMessageTrash.AuthTypes)
}
if len(MailMessageTrash.Scopes) != 1 || MailMessageTrash.Scopes[0] != "mail:user_mailbox.message:modify" {
t.Errorf("Scopes = %v, want [mail:user_mailbox.message:modify]", MailMessageTrash.Scopes)
}
}
func TestMessageModify_LabelOnlyDoesNotRequireFolderReadScope(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
token := auth.GetStoredToken("test-app", "ou_testuser")
if token == nil {
t.Fatal("expected test token")
}
token.Scope = strings.ReplaceAll(token.Scope, " mail:user_mailbox.folder:read", "")
if err := auth.SetStoredToken(token); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
id := messageManageID("1")
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--remove-label-ids", "UNREAD",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
removeLabels := body["remove_label_ids"].([]interface{})
if len(removeLabels) != 1 || removeLabels[0] != "UNREAD" {
t.Fatalf("remove_label_ids = %#v, want [UNREAD]", removeLabels)
}
}
func TestMessageModify_ReadReceiptRequestLabelIsSystemLabel(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--remove-label-ids", "read_receipt_request",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
removeLabels := body["remove_label_ids"].([]interface{})
if len(removeLabels) != 1 || removeLabels[0] != "READ_RECEIPT_REQUEST" {
t.Fatalf("remove_label_ids = %#v, want [READ_RECEIPT_REQUEST]", removeLabels)
}
}
func TestMessageModify_LabelFolderNormalizationAndValidationAPIs(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/labels/customA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"label_id": "customA"}}})
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/folders/folderA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"folder_id": "folderA"}}})
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-label-ids", "unread,customA",
"--remove-label-ids", "FLAGGED",
"--add-folder", "folderA",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if got := body["add_folder"]; got != "folderA" {
t.Errorf("add_folder = %v, want folderA", got)
}
addLabels := body["add_label_ids"].([]interface{})
if addLabels[0] != "UNREAD" || addLabels[1] != "customA" {
t.Errorf("add_label_ids = %#v, want [UNREAD customA]", addLabels)
}
removeLabels := body["remove_label_ids"].([]interface{})
if removeLabels[0] != "FLAGGED" {
t.Errorf("remove_label_ids = %#v, want [FLAGGED]", removeLabels)
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 1 || success[0] != id || len(failed) != 0 {
t.Errorf("summary success=%v failed=%v", success, failed)
}
}
func TestMessageModify_RejectsLabelIntersectionAndTrashFolder(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id := messageManageID("1")
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-label-ids", "unread",
"--remove-label-ids", "UNREAD",
}, f, stdout)
requireMessageManageValidationParam(t, err, "--add-label-ids")
if !strings.Contains(err.Error(), "label cannot be both added and removed") {
t.Fatalf("error = %v, want label intersection validation", err)
}
err = runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-folder", "trash",
}, f, stdout)
requireMessageManageValidationParam(t, err, "--add-folder")
if !strings.Contains(err.Error(), "use +message-trash") {
t.Fatalf("error = %v, want TRASH validation", err)
}
}
func TestMessageModify_EmptyOperationDoesNotCallPost(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id1 := messageManageID("1")
id2 := messageManageID("2")
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id1 + "," + id2 + "," + id1,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 2 || success[0] != id1 || success[1] != id2 || len(failed) != 0 {
t.Fatalf("summary success=%v failed=%v", success, failed)
}
}
func TestMessageModify_BatchesAndAggregatesPartialFailure(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
ids := make([]string, 41)
for i := range ids {
ids[i] = messageManageID(fmt.Sprintf("%02d", i))
}
first := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
second := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
third := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", strings.Join(ids, ","),
"--add-folder", "archive",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
for idx, stub := range []*httpmock.Stub{first, second, third} {
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("batch %d body unmarshal: %v", idx+1, err)
}
messageIDs := body["message_ids"].([]interface{})
want := []int{20, 20, 1}[idx]
if len(messageIDs) != want {
t.Fatalf("batch %d size = %d, want %d", idx+1, len(messageIDs), want)
}
if body["add_folder"] != "ARCHIVED" {
t.Fatalf("batch %d add_folder = %v, want ARCHIVED", idx+1, body["add_folder"])
}
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 21 || len(failed) != 20 {
t.Fatalf("success=%d failed=%d, want 21/20", len(success), len(failed))
}
}
func TestMessageModify_AllBatchesFailReturnsError(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-folder", "archive",
}, f, stdout)
requireMessageManageFailedPrecondition(t, err)
}
func TestMessageModify_DryRunShowsPlanWithoutValidationGET(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id1 := messageManageID("1")
id2 := messageManageID("2")
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id1 + "," + id2,
"--add-label-ids", "customA",
"--add-folder", "folderA",
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("dry-run failed: %v", err)
}
out := stdout.String()
for _, want := range []string{
`/user_mailboxes/me/messages/batch_modify`,
`validation_api_plan`,
`/user_mailboxes/me/labels/customA`,
`/user_mailboxes/me/folders/folderA`,
`will_validate`,
`batch_size`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q; got %s", want, out)
}
}
}
func TestMessageTrash_RequiresYesAndBatches(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id1 := messageManageID("1")
id2 := messageManageID("2")
err := runMountedMailShortcut(t, MailMessageTrash, []string{
"+message-trash",
"--message-ids", id1 + "," + id2,
}, f, stdout)
if err == nil {
t.Fatal("expected confirmation error, got nil")
}
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
t.Fatalf("exit code = %d, want %d", code, output.ExitConfirmationRequired)
}
post := stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err = runMountedMailShortcut(t, MailMessageTrash, []string{
"+message-trash",
"--message-ids", id1 + "," + id2,
"--yes",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err with --yes: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if got := len(body["message_ids"].([]interface{})); got != 2 {
t.Fatalf("message_ids len = %d, want 2", got)
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 2 || len(failed) != 0 {
t.Fatalf("summary success=%v failed=%v", success, failed)
}
}
func TestMessageTrash_AllBatchesFailReturnsError(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 1230001, "msg": "bad request"})
err := runMountedMailShortcut(t, MailMessageTrash, []string{
"+message-trash",
"--message-ids", id,
"--yes",
}, f, stdout)
requireMessageManageFailedPrecondition(t, err)
}
func TestMessageManage_RejectsWhitespaceBeforeAPI(t *testing.T) {
id1 := messageManageID("1")
id2 := messageManageID("2")
cases := []struct {
name string
shortcut common.Shortcut
args []string
}{
{
name: "trash newline in repeated flag",
shortcut: MailMessageTrash,
args: []string{"+message-trash", "--message-ids", id1 + "\n" + id2, "--yes"},
},
{
name: "trash tab in csv flag",
shortcut: MailMessageTrash,
args: []string{"+message-trash", "--message-ids", id1 + ",\t" + id2, "--yes"},
},
{
name: "modify space in repeated flag",
shortcut: MailMessageModify,
args: []string{"+message-modify", "--message-ids", id1, "--message-ids", id2 + " ", "--add-folder", "archive"},
},
{
name: "modify space in csv flag",
shortcut: MailMessageModify,
args: []string{"+message-modify", "--message-ids", id1 + ", " + id2, "--add-folder", "archive"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, tc.shortcut, tc.args, f, stdout)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d; err=%v", code, output.ExitValidation, err)
}
if !strings.Contains(err.Error(), "must not contain whitespace or control characters") {
t.Fatalf("error = %v, want whitespace/control validation", err)
}
})
}
}

View File

@@ -0,0 +1,141 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
type messageModifyInput struct {
MessageIDs []string
AddLabelIDs []string
RemoveLabelIDs []string
AddFolder string
CustomLabelIDs []string
CustomFolderID string
ValidationAPIPlans []validationAPIPlan
}
// MailMessageModify is the `+message-modify` shortcut: apply labels, unread
// state labels, or a folder move to existing messages in batches of 20.
var MailMessageModify = common.Shortcut{
Service: "mail",
Command: "+message-modify",
Description: "Modify existing mail messages by adding/removing label IDs or moving them to a folder. Batches message IDs in groups of 20 and keeps output compact.",
Risk: "write",
Scopes: []string{"mail:user_mailbox.message:modify"},
ConditionalScopes: []string{
"mail:user_mailbox.folder:read",
},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to modify; comma-separated or repeat the flag."},
{Name: "add-label-ids", Type: "string_slice", Desc: "Label IDs to add. System labels unread/important/other/flagged are normalized to upper case."},
{Name: "remove-label-ids", Type: "string_slice", Desc: "Label IDs to remove. System labels unread/important/other/flagged are normalized to upper case."},
{Name: "add-folder", Desc: "Folder ID to move messages to. System folders inbox/sent/spam/archive/archived are normalized; TRASH is rejected, use +message-trash."},
},
Validate: validateMessageModify,
DryRun: dryRunMessageModify,
Execute: executeMessageModify,
}
func validateMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
_, err := buildMessageModifyInput(rt)
return err
}
func dryRunMessageModify(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(rt)
input, _ := buildMessageModifyInput(rt)
api := common.NewDryRunAPI().
Desc("Modify messages sequentially in batches of 20; dry-run does not call label/folder validation APIs").
Set("batch_size", mailMessageManageBatchSize).
Set("batches", chunkMessageManageIDs(input.MessageIDs)).
Set("validation_api_plan", input.ValidationAPIPlans)
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
api = api.POST(mailboxPath(mailboxID, "messages", "batch_modify")).
Body(messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
}
return api
}
func executeMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
mailboxID := resolveMailboxID(rt)
input, err := buildMessageModifyInput(rt)
if err != nil {
return err
}
if err := validateCustomMessageManageLabels(rt, mailboxID, input.CustomLabelIDs); err != nil {
return err
}
if err := validateCustomMessageManageFolder(rt, mailboxID, input.CustomFolderID); err != nil {
return err
}
if len(input.AddLabelIDs) == 0 && len(input.RemoveLabelIDs) == 0 && input.AddFolder == "" {
emitMessageManageSummary(rt, messageManageSummary{
SuccessMessageIDs: input.MessageIDs,
FailedMessageIDs: []messageManageFailure{},
}, true)
return nil
}
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_modify"), nil,
messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
if err != nil {
for _, id := range batch {
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
}
continue
}
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
}
emitMessageManageSummary(rt, summary, false)
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
return mailFailedPreconditionError("all message modify batches failed")
}
return nil
}
func buildMessageModifyInput(rt *common.RuntimeContext) (messageModifyInput, error) {
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
if err != nil {
return messageModifyInput{}, err
}
addLabels, customAddLabels, err := normalizeMessageManageLabels(rt.StrSlice("add-label-ids"), "--add-label-ids")
if err != nil {
return messageModifyInput{}, err
}
removeLabels, customRemoveLabels, err := normalizeMessageManageLabels(rt.StrSlice("remove-label-ids"), "--remove-label-ids")
if err != nil {
return messageModifyInput{}, err
}
if err := validateLabelIntersection(addLabels, removeLabels); err != nil {
return messageModifyInput{}, err
}
folder, customFolder, err := normalizeMessageManageFolder(rt.Str("add-folder"))
if err != nil {
return messageModifyInput{}, err
}
customLabels := append(customAddLabels, customRemoveLabels...)
customFolderID := ""
if customFolder {
customFolderID = folder
}
return messageModifyInput{
MessageIDs: messageIDs,
AddLabelIDs: addLabels,
RemoveLabelIDs: removeLabels,
AddFolder: folder,
CustomLabelIDs: customLabels,
CustomFolderID: customFolderID,
ValidationAPIPlans: messageManageValidationPlan(resolveMailboxID(rt), customLabels, customFolderID),
}, nil
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// MailMessageTrash is the `+message-trash` shortcut: soft-delete existing
// messages in batches of 20 via batch_trash. Risk is high-risk-write, so the
// runner requires --yes before Execute.
var MailMessageTrash = common.Shortcut{
Service: "mail",
Command: "+message-trash",
Description: "Soft-delete existing mail messages. Batches message IDs in groups of 20 and calls batch_trash sequentially. Requires --yes.",
Risk: "high-risk-write",
Scopes: []string{"mail:user_mailbox.message:modify"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to soft-delete; comma-separated or repeat the flag."},
},
Validate: validateMessageTrash,
DryRun: dryRunMessageTrash,
Execute: executeMessageTrash,
}
func validateMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
_, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
return err
}
func dryRunMessageTrash(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(rt)
messageIDs, _ := normalizeMessageManageIDs(rt.StrArray("message-ids"))
api := common.NewDryRunAPI().
Desc("Soft-delete messages sequentially in batches of 20").
Set("batch_size", mailMessageManageBatchSize).
Set("batches", chunkMessageManageIDs(messageIDs))
for _, batch := range chunkMessageManageIDs(messageIDs) {
api = api.POST(mailboxPath(mailboxID, "messages", "batch_trash")).
Body(map[string]interface{}{"message_ids": batch})
}
return api
}
func executeMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
mailboxID := resolveMailboxID(rt)
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
if err != nil {
return err
}
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
for _, batch := range chunkMessageManageIDs(messageIDs) {
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_trash"), nil,
map[string]interface{}{"message_ids": batch})
if err != nil {
for _, id := range batch {
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
}
continue
}
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
}
emitMessageManageSummary(rt, summary, false)
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
return mailFailedPreconditionError("all message trash batches failed")
}
return nil
}

View File

@@ -44,7 +44,7 @@ func mailShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *by
RefreshToken: "test-refresh-token",
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly mail:user_mailbox.folder:read",
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
}
if err := auth.SetStoredToken(token); err != nil {

View File

@@ -0,0 +1,283 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"fmt"
"io"
"strings"
"unicode"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const mailMessageManageBatchSize = 20
var messageManageSystemLabels = map[string]string{
"UNREAD": "UNREAD",
"IMPORTANT": "IMPORTANT",
"OTHER": "OTHER",
"FLAGGED": "FLAGGED",
"READ_RECEIPT_REQUEST": "READ_RECEIPT_REQUEST",
}
var messageManageSystemFolders = map[string]string{
"INBOX": "INBOX",
"SENT": "SENT",
"SPAM": "SPAM",
"ARCHIVE": "ARCHIVED",
"ARCHIVED": "ARCHIVED",
}
type messageManageSummary struct {
SuccessMessageIDs []string `json:"success_message_ids"`
FailedMessageIDs []messageManageFailure `json:"failed_message_ids"`
}
type messageManageFailure struct {
MessageID string `json:"message_id"`
Reason string `json:"reason"`
}
type validationAPIPlan struct {
Method string `json:"method"`
Path string `json:"path"`
WillValidate bool `json:"will_validate"`
}
func normalizeMessageManageIDs(raw []string) ([]string, error) {
if len(raw) == 0 {
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
}
parts, err := splitMessageManageIDTokens(raw)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for i, part := range parts {
if part == "" {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
}
id := strings.TrimSpace(part)
if id == "" {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
}
if id != part {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
}
if err := validateMessageManageID(id, i); err != nil {
return nil, err
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
}
return ids, nil
}
func splitMessageManageIDTokens(raw []string) ([]string, error) {
parts := make([]string, 0, len(raw))
for i, token := range raw {
for _, r := range token {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", i+1, token)
}
}
parts = append(parts, strings.Split(token, ",")...)
}
return parts, nil
}
func validateMessageManageID(id string, index int) error {
if len(id) < 16 {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): length must be at least 16 characters", index+1, id)
}
if strings.Trim(id, "0123456789") == "" {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): numeric primary IDs are not supported; pass the Open API message_id from mail output", index+1, id)
}
for _, r := range id {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", index+1, id)
}
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
continue
}
switch r {
case '+', '/', '=', '_', '-':
continue
default:
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): contains characters outside the Open API message_id character set", index+1, id)
}
}
return nil
}
func normalizeMessageManageLabels(raw []string, flagName string) ([]string, []string, error) {
labels := make([]string, 0, len(raw))
custom := make([]string, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
for i, part := range raw {
id := strings.TrimSpace(part)
if id == "" {
return nil, nil, mailValidationParamError(flagName, "%s entry %d is empty; remove extra commas or provide valid label IDs", flagName, i+1)
}
if id != part {
return nil, nil, mailValidationParamError(flagName, "%s entry %d (%q): must not contain leading or trailing whitespace", flagName, i+1, part)
}
normalized := id
if system, ok := messageManageSystemLabels[strings.ToUpper(id)]; ok {
normalized = system
} else {
custom = append(custom, id)
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
labels = append(labels, normalized)
}
if len(labels) > 20 {
return nil, nil, mailValidationParamError(flagName, "%s accepts at most 20 label IDs (got %d)", flagName, len(labels))
}
return labels, custom, nil
}
func validateLabelIntersection(add, remove []string) error {
removeSet := make(map[string]struct{}, len(remove))
for _, id := range remove {
removeSet[id] = struct{}{}
}
for _, id := range add {
if _, ok := removeSet[id]; ok {
return mailValidationParamError("--add-label-ids", "label cannot be both added and removed: %s", id)
}
}
return nil
}
func normalizeMessageManageFolder(raw string) (string, bool, error) {
if raw == "" {
return "", false, nil
}
folder := strings.TrimSpace(raw)
if folder == "" {
return "", false, mailValidationParamError("--add-folder", "--add-folder must not be empty")
}
if folder != raw {
return "", false, mailValidationParamError("--add-folder", "--add-folder %q must not contain leading or trailing whitespace", raw)
}
if strings.EqualFold(folder, "TRASH") {
return "", false, mailValidationParamError("--add-folder", "TRASH is not supported by +message-modify; use +message-trash")
}
if system, ok := messageManageSystemFolders[strings.ToUpper(folder)]; ok {
return system, false, nil
}
return folder, true, nil
}
func chunkMessageManageIDs(ids []string) [][]string {
if len(ids) == 0 {
return nil
}
chunks := make([][]string, 0, (len(ids)+mailMessageManageBatchSize-1)/mailMessageManageBatchSize)
for start := 0; start < len(ids); start += mailMessageManageBatchSize {
end := start + mailMessageManageBatchSize
if end > len(ids) {
end = len(ids)
}
chunks = append(chunks, ids[start:end])
}
return chunks
}
func validateCustomMessageManageLabels(rt *common.RuntimeContext, mailboxID string, ids []string) error {
if len(ids) == 0 {
return nil
}
if err := validateLabelReadScope(rt); err != nil {
return err
}
seen := map[string]struct{}{}
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "labels", id), nil, nil); err != nil {
return mailDecorateProblemMessage(err, "label not found: %s", id)
}
}
return nil
}
func validateCustomMessageManageFolder(rt *common.RuntimeContext, mailboxID, id string) error {
if id == "" {
return nil
}
if err := validateFolderReadScope(rt); err != nil {
return err
}
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "folders", id), nil, nil); err != nil {
return mailDecorateProblemMessage(err, "folder not found: %s", id)
}
return nil
}
func messageManageBody(ids, addLabels, removeLabels []string, addFolder string) map[string]interface{} {
body := map[string]interface{}{"message_ids": ids}
if len(addLabels) > 0 {
body["add_label_ids"] = addLabels
}
if len(removeLabels) > 0 {
body["remove_label_ids"] = removeLabels
}
if addFolder != "" {
body["add_folder"] = addFolder
}
return body
}
func messageManageValidationPlan(mailboxID string, customLabels []string, customFolder string) []validationAPIPlan {
plans := make([]validationAPIPlan, 0, len(customLabels)+1)
seenLabels := map[string]struct{}{}
for _, id := range customLabels {
if _, ok := seenLabels[id]; ok {
continue
}
seenLabels[id] = struct{}{}
plans = append(plans, validationAPIPlan{
Method: "GET",
Path: mailboxPath(mailboxID, "labels", id),
WillValidate: true,
})
}
if customFolder != "" {
plans = append(plans, validationAPIPlan{
Method: "GET",
Path: mailboxPath(mailboxID, "folders", customFolder),
WillValidate: true,
})
}
return plans
}
func emitMessageManageSummary(rt *common.RuntimeContext, summary messageManageSummary, noAPICalls bool) {
rt.OutFormat(summary, &output.Meta{Count: len(summary.SuccessMessageIDs)}, func(w io.Writer) {
fmt.Fprintf(w, "success_message_ids: %d\n", len(summary.SuccessMessageIDs))
fmt.Fprintf(w, "failed_message_ids: %d\n", len(summary.FailedMessageIDs))
if noAPICalls {
fmt.Fprintln(w, "No changes requested; no API calls were made.")
}
for _, item := range summary.FailedMessageIDs {
fmt.Fprintf(w, "- %s: %s\n", item.MessageID, item.Reason)
}
})
}

View File

@@ -10,6 +10,8 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
MailMessage,
MailMessages,
MailMessageModify,
MailMessageTrash,
MailThread,
MailTriage,
MailWatch,

View File

@@ -65,7 +65,7 @@
1. `+triage --from spam@x.com` → 列出 N 条结果
2. 展示:"将删除 N 封邮件(发件人 spam@x.com主题确认"
3. 用户确认后 → `*.batch_trash`
3. 用户确认后 → `+message-trash --message-ids ... --yes`
## 身份选择:优先使用 user 身份
@@ -82,12 +82,13 @@
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
2. **浏览**`+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
3. **阅读**`+message` 读单封邮件,`+thread` 读整个会话
4. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
5. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
7. **确认投递** 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
8. **编辑草稿**`+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
9. **已读回执**
4. **整理**标签、已读/未读状态和移动文件夹优先用 `+message-modify`;软删除优先用 `+message-trash`
5. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送
7. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
8. **确认投递**立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
9. **编辑草稿** `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
10. **已读回执**
- **请求回执(写信侧)**`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
- **响应回执(拉信侧)**:拉信看到 `label_ids``READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
@@ -417,7 +418,7 @@ lark-cli mail +message --message-id <id>
## 原生 API 调用规则
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准API Resources 章节的 resource/method 列表可辅助查阅)。
没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`。调用步骤以本节为准API Resources 章节的 resource/method 列表可辅助查阅)。
### Step 1 — 用 `-h` 确定要调用的 API必须不可跳过

View File

@@ -10,7 +10,6 @@
- 必填:`--app-id`
- 可选:`--dir`clone 目标目录;省略时默认 `./<app-id>`
- 可选:`--template`,空仓库脚手架模板;省略时当前回退 `nestjs-react-fullstack`
- 固定 checkout 分支:`sprint/default`
- `+init` 会初始化 Git 凭证、clone 仓库、切到工作分支并生成/同步本地项目。
@@ -18,7 +17,7 @@
```bash
lark-cli apps +init --app-id app_xxx --dir ./my-app
lark-cli apps +init --app-id app_xxx --dir /absolute/path/my-app --template nestjs-react-fullstack
lark-cli apps +init --app-id app_xxx --dir /absolute/path/my-app
lark-cli apps +init --app-id app_xxx --dir ./my-app --dry-run
```

View File

@@ -21,8 +21,10 @@ lark-cli apps +release-create --app-id app_xxx --branch sprint/default --dry-run
## 输出契约
- 成功读取 `data.release_id``data.status``release_id` 是后续 `+release-get` 的入参。
- 成功读取 `data.release_id``data.status``data.sync``release_id` 是后续 `+release-get` 的入参。
- `sync=true` 表示同步部署(服务端等待部署完成后才返回),`sync=false` 或缺失表示异步部署。
- `status=publishing` 表示发布仍在进行;继续用 `+release-get` 轮询,轮询间隔应该为 20s。应用发布平均耗时大约 2min整体超时时间大约 5min。
- `status=finished` 表示部署已完成(同步部署时可能直接返回此状态)。
- `+release-create` 返回 release 只代表发布已发起。只有 `+release-get` 对同一个 `release_id` 返回 `finished` 后,才能说本轮最新版本已部署。
## Agent 规则

View File

@@ -79,7 +79,7 @@ metadata:
1. `+triage --from spam@x.com` → 列出 N 条结果
2. 展示:"将删除 N 封邮件(发件人 spam@x.com主题确认"
3. 用户确认后 → `*.batch_trash`
3. 用户确认后 → `+message-trash --message-ids ... --yes`
## 身份选择:优先使用 user 身份
@@ -96,13 +96,14 @@ metadata:
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
2. **浏览**`+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
3. **阅读**`+message` 只读单封邮件;已有多个 `message_id` 时用 `+messages` 批量读取,不要循环调用 `+message``+thread` 读整个会话
4. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
5. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
7. **HTML body 预检(可选)** — 复杂 HTML body 提交前可先跑 `+lint-html` 看 lint 会改 / 删什么;写信路径(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op已内置 autofix普通正文不必先跑。详见 [references/lark-mail-html.md](references/lark-mail-html.md) 中的「写入路径内置 HTML lint」章节
8. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
9. **编辑草稿**`+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
10. **已读回执**
4. **整理**标签、已读/未读状态和移动文件夹优先用 `+message-modify`;软删除优先用 `+message-trash`
5. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送
7. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
8. **HTML body 预检(可选)** — 复杂 HTML body 提交前可先跑 `+lint-html` 看 lint 会改 / 删什么;写信路径(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op已内置 autofix普通正文不必先跑。详见 [references/lark-mail-html.md](references/lark-mail-html.md) 中的「写入路径内置 HTML lint」章节
9. **确认投递**立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
10. **编辑草稿** `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
11. **已读回执**
- **请求回执(写信侧)**`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
- **响应回执(拉信侧)**:拉信看到 `label_ids``READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
@@ -119,6 +120,8 @@ metadata:
- 查看发送邮件后的投递状态发送成功后查看邮件投递状态也覆盖发送拦截。ref: [lark-mail-send-status](references/lark-mail-send-status.md)
- 使用邮件模板:区分个人模板和静态 HTML 模板,发信类 shortcut 用 `--template-id` 套用模板。ref: [lark-mail-template](references/lark-mail-template.md)
- 撤回已发送邮件撤回邮件并查询异步撤回状态。ref: [lark-mail-recall](references/lark-mail-recall.md)
- 修改邮件标签/已读状态/文件夹:优先使用 `+message-modify`。ref: [`+message-modify`](references/lark-mail-message-modify.md)
- 软删除邮件:优先使用 `+message-trash`。ref: [`+message-trash`](references/lark-mail-message-trash.md)
- 收信规则创建、验证、删除自动处理收到邮件的规则。ref: [lark-mail-rules](references/lark-mail-rules.md)
- 分享邮件到 IM分享邮件或会话到群聊、个人会话。ref: [lark-mail-share-to-chat](references/lark-mail-share-to-chat.md)
- 发送日程邀请邮件:在邮件中嵌入 `text/calendar` 日程邀请。ref: [lark-mail-calendar-invite](references/lark-mail-calendar-invite.md)
@@ -192,7 +195,7 @@ lark-cli mail +messages --message-ids <id1>,<id2>,<id3> --html=false
## 原生 API 调用规则
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准资源和 method 用 `lark-cli mail -h` / `lark-cli mail <resource> -h` 发现,不在入口保留完整资源表。
没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`调用步骤以本节为准;资源和 method 用 `lark-cli mail -h` / `lark-cli mail <resource> -h` 发现,不在入口保留完整资源表。
### Step 1 — 用 `-h` 确定要调用的 API必须不可跳过

View File

@@ -215,7 +215,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
```bash
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
```
## 编辑转发草稿

View File

@@ -0,0 +1,48 @@
# mail +message-modify
`mail +message-modify` is the preferred shortcut for changing labels, read-state labels, or folder placement on existing messages.
Use it instead of raw `user_mailbox.messages batch_modify` when the operation targets concrete `message_id` values from `+triage`, `+message`, or `+messages`.
## Common Commands
```bash
lark-cli mail +message-modify --message-ids <id1>,<id2> --add-label-ids unread
lark-cli mail +message-modify --message-ids <id> --remove-label-ids FLAGGED
lark-cli mail +message-modify --message-ids <id> --add-folder archive
lark-cli mail +message-modify --mailbox shared@example.com --message-ids <id> --add-folder folder_xxx
lark-cli mail +message-modify --message-ids <id> --add-label-ids custom_label_id --dry-run
```
## Flags
| Flag | Required | Notes |
| --- | --- | --- |
| `--mailbox` | No | Mailbox that owns the messages. Defaults to `me`. |
| `--message-ids` | Yes | `string_array`; supports comma-separated values and repeated flags. |
| `--add-label-ids` | No | Adds labels. System labels `unread`, `important`, `other`, `flagged` normalize to upper case. |
| `--remove-label-ids` | No | Removes labels. Cannot overlap with `--add-label-ids`. |
| `--add-folder` | No | Moves to one folder. `inbox`, `sent`, `spam`, `archive`, `archived` normalize to system folder IDs. |
`TRASH` is intentionally rejected by this shortcut. Use `mail +message-trash --message-ids <id> --yes` for soft deletion.
## Behavior
- Message IDs are locally validated, de-duplicated in first-seen order, and sent in batches of 20.
- Custom label IDs are checked with `labels.get`; custom folder IDs are checked with `folders.get`.
- If no label or folder operation is requested, the command succeeds locally, emits all message IDs as `success_message_ids`, and makes no POST request.
- Single batch POST failures mark every message in that batch with the same failure reason; later batches still run.
- JSON output is intentionally compact:
```json
{
"success_message_ids": ["id1"],
"failed_message_ids": [
{"message_id": "id2", "reason": "api error"}
]
}
```
## When Raw API Is Still Appropriate
Use raw `mail user_mailbox.messages batch_modify` only when you need a request shape that the shortcut intentionally does not expose, or when reproducing backend/API behavior exactly for diagnostics.

View File

@@ -0,0 +1,41 @@
# mail +message-trash
`mail +message-trash` is the preferred shortcut for soft-deleting existing messages.
Use it after obtaining real `message_id` values from `+triage`, `+message`, or `+messages`, and after the user has confirmed the deletion preview.
## Common Commands
```bash
lark-cli mail +message-trash --message-ids <id1>,<id2> --yes
lark-cli mail +message-trash --mailbox shared@example.com --message-ids <id> --yes
lark-cli mail +message-trash --message-ids <id1> --message-ids <id2> --dry-run
```
## Flags
| Flag | Required | Notes |
| --- | --- | --- |
| `--mailbox` | No | Mailbox that owns the messages. Defaults to `me`. |
| `--message-ids` | Yes | `string_array`; supports comma-separated values and repeated flags. |
| `--yes` | Yes for execution | Required by the high-risk write confirmation framework. |
## Behavior
- Message IDs are locally validated, de-duplicated in first-seen order, and sent in batches of 20.
- The shortcut calls `POST /open-apis/mail/v1/user_mailboxes/<mailbox>/messages/batch_trash` sequentially.
- Single batch POST failures mark every message in that batch with the same failure reason; later batches still run.
- JSON output is intentionally compact:
```json
{
"success_message_ids": ["id1"],
"failed_message_ids": [
{"message_id": "id2", "reason": "api error"}
]
}
```
## When Raw API Is Still Appropriate
Use raw `mail user_mailbox.messages batch_trash` only when reproducing backend/API behavior exactly for diagnostics. For normal soft deletion, prefer this shortcut because it handles validation, batching, compact output, and `--yes` confirmation consistently.

View File

@@ -203,7 +203,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
```bash
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
```
## 相关命令

View File

@@ -218,7 +218,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
```bash
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
```
## 编辑回复草稿

View File

@@ -1,9 +1,9 @@
# Mail CLI E2E Coverage
## Metrics
- Denominator: 63 leaf commands
- Covered: 14
- Coverage: 22.2%
- Denominator: 65 leaf commands
- Covered: 16
- Coverage: 24.6%
## Summary
- TestMail_DraftLifecycleWorkflowAsUser: proves a self-contained user draft workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail user_mailbox.drafts list`, `mail user_mailbox.drafts get`, `mail +draft-edit`, and `mail user_mailbox.drafts delete`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create draft with shortcut as user`, `list draft as user`, `get created draft as user`, `inspect created draft as user`, `update draft subject with shortcut as user`, `inspect updated draft as user`, `delete draft as user`, and `verify draft removed from list as user`.
@@ -20,6 +20,8 @@
| ✓ | mail +draft-send | shortcut | mail_draft_send_workflow_test.go::TestMail_DraftSendWorkflowAsUser/send draft with shortcut as user; mail_draft_send_dryrun_test.go::TestMail_DraftSendDryRun | `--draft-id`; `--mailbox me`; `--yes`; dry-run repeated/comma-separated `--draft-id` | sends a self-addressed draft through the batch shortcut and locks dry-run request shape |
| ✓ | mail +forward | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/forward received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect forward draft as user | `--message-id`; `--to`; `--body`; `--plain-text` | uses self-generated inbox message as source and inspects forwarded draft projection |
| ✓ | mail +message | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get sent message as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get received message as user | `--mailbox me`; `--message-id` | verifies both SENT and INBOX copies after self-send |
| ✓ | mail +message-modify | shortcut | shortcuts/mail/mail_message_manage_test.go::TestMessageModify_DryRunShowsPlanWithoutValidationGET; shortcuts/mail/mail_message_manage_test.go::TestMessageModify_BatchesAndAggregatesPartialFailure | `--message-ids`; `--add-label-ids`; `--remove-label-ids`; `--add-folder`; `--dry-run` | unit/dry-run coverage locks validation, batching, request shape, and partial failure aggregation; live E2E needs controlled disposable messages/labels/folders |
| ✓ | mail +message-trash | shortcut | shortcuts/mail/mail_message_manage_test.go::TestMessageTrash_RequiresYesAndBatches | `--message-ids`; `--yes`; `--dry-run` | unit coverage locks high-risk confirmation and batch_trash request shape; live E2E needs controlled disposable messages |
| ✓ | mail +messages | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get both self sent messages as user | `--mailbox me`; `--message-ids` | batch reads both sent and received message copies |
| ✓ | mail +reply | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/reply to received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect reply draft as user | `--message-id`; `--body`; `--plain-text` | creates reply draft from self-generated inbox message and inspects quoted content |
| ✕ | mail +reply-all | shortcut | | none | self-send traffic leaves no stable non-self recipient set for deterministic reply-all assertions |