' over creating a new draft via '+draft-create'. Re-running '+draft-create' will produce a separate draft entry instead of updating the existing one."
+
+// addDraftEditHint inserts the draft-edit recommendation into the envelope
+// data map under the key `draft_edit_hint`. ONLY +draft-create calls this —
+// the other 5 compose shortcuts (+send / +reply / +reply-all / +forward /
+// +draft-edit) MUST NOT attach `draft_edit_hint`: it only applies to a newly
+// created draft, not to a sent message or an edit of an existing draft.
+func addDraftEditHint(out map[string]interface{}) {
+ out["draft_edit_hint"] = draftEditHintConst
+}
diff --git a/shortcuts/mail/mail_lint_writepath_test.go b/shortcuts/mail/mail_lint_writepath_test.go
new file mode 100644
index 000000000..5b145d87a
--- /dev/null
+++ b/shortcuts/mail/mail_lint_writepath_test.go
@@ -0,0 +1,719 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package mail
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/larksuite/cli/internal/httpmock"
+ "github.com/larksuite/cli/shortcuts/mail/lint"
+)
+
+// jsonDecoderUnmarshal is a thin alias used by helpers in this file to keep
+// the import set explicit even when the helper would otherwise be one-line.
+func jsonDecoderUnmarshal(b []byte, v interface{}) error { return json.Unmarshal(b, v) }
+
+// =====================================================================
+// Writing-path lint integration tests — compose 5 + +draft-edit emit
+// `lint_applied[]` and `original_blocked[]` arrays in the stdout envelope
+// always.
+// =====================================================================
+
+// TestRunWritePathLint_PlainTextReturnsEmptyReport verifies the helper
+// short-circuits on plain-text input.
+func TestRunWritePathLint_PlainTextReturnsEmptyReport(t *testing.T) {
+ cleaned, rep := runWritePathLint("")
+ if cleaned != "" {
+ t.Errorf("cleaned = %q, want empty", cleaned)
+ }
+ if rep.Applied == nil || rep.Blocked == nil {
+ t.Error("Applied/Blocked must be non-nil")
+ }
+ if len(rep.Applied) != 0 || len(rep.Blocked) != 0 {
+ t.Errorf("expected empty report, got applied=%d blocked=%d",
+ len(rep.Applied), len(rep.Blocked))
+ }
+}
+
+// TestRunWritePathLint_HTMLAlwaysAutofixedWarningNeverElevated verifies the
+// writing path always autofixes warnings and never elevates them — the
+// writing-path safety contract has no opt-out. The input
+// triggers two warning autofixes ( paragraph-rewrite + tag
+// rewrite); both must surface in `Applied` and never appear in `Blocked`.
+func TestRunWritePathLint_HTMLAlwaysAutofixedWarningNeverElevated(t *testing.T) {
+ cleaned, rep := runWritePathLint(`x
`)
+ if !strings.Contains(cleaned, ", cleaned=%q", cleaned)
+ }
+ if strings.Contains(cleaned, "") || strings.Contains(cleaned, "/ rewritten, cleaned=%q", cleaned)
+ }
+ if len(rep.Applied) < 1 {
+ t.Errorf("expected ≥1 warning surfaced (font + paragraph autofix), got %d", len(rep.Applied))
+ }
+ // Warnings never become errors on the writing-path; --strict no longer
+ // exists at the surface either, so the contract is "Applied gathers
+ // warnings, Blocked stays empty for warning-only inputs".
+ if len(rep.Blocked) != 0 {
+ t.Errorf("writing-path must NOT elevate warnings; expected 0 blocked, got %d", len(rep.Blocked))
+ }
+}
+
+// TestApplyLintToEnvelope_DefaultEmitsNoLintFields verifies the helper writes
+// zero keys in the default (non-detail) mode — neither count fields nor the
+// full Finding arrays appear; the envelope stays small.
+func TestApplyLintToEnvelope_DefaultEmitsNoLintFields(t *testing.T) {
+ data := map[string]interface{}{"existing": "value"}
+ rep := lint.EmptyReport(`x
`)
+ applyLintToEnvelope(data, rep.Applied, rep.Blocked, false)
+
+ if data["existing"] != "value" {
+ t.Error("existing key was clobbered")
+ }
+ if _, ok := data["lint_applied_count"]; ok {
+ t.Error("lint_applied_count must NOT be present in default mode")
+ }
+ if _, ok := data["original_blocked_count"]; ok {
+ t.Error("original_blocked_count must NOT be present in default mode")
+ }
+ if _, ok := data["lint_applied"]; ok {
+ t.Error("lint_applied[] must NOT be present in default mode")
+ }
+ if _, ok := data["original_blocked"]; ok {
+ t.Error("original_blocked[] must NOT be present in default mode")
+ }
+}
+
+// TestApplyLintToEnvelope_DetailModeIncludesArrays verifies the detail mode
+// (showDetails=true) attaches the two non-nil Finding arrays only. The
+// `*_count` fields are no longer emitted (callers can compute counts via
+// `len(arr)` themselves).
+func TestApplyLintToEnvelope_DetailModeIncludesArrays(t *testing.T) {
+ data := map[string]interface{}{}
+ rep := lint.EmptyReport(`x
`)
+ applyLintToEnvelope(data, rep.Applied, rep.Blocked, true)
+
+ if _, ok := data["lint_applied_count"]; ok {
+ t.Error("lint_applied_count must NOT be present (count fields removed)")
+ }
+ if _, ok := data["original_blocked_count"]; ok {
+ t.Error("original_blocked_count must NOT be present (count fields removed)")
+ }
+ la, ok := data["lint_applied"].([]lint.Finding)
+ if !ok {
+ t.Fatalf("lint_applied wrong type: %T", data["lint_applied"])
+ }
+ if la == nil {
+ t.Error("lint_applied is nil — must be empty slice in detail mode")
+ }
+ ob, ok := data["original_blocked"].([]lint.Finding)
+ if !ok {
+ t.Fatalf("original_blocked wrong type: %T", data["original_blocked"])
+ }
+ if ob == nil {
+ t.Error("original_blocked is nil — must be empty slice in detail mode")
+ }
+}
+
+// =====================================================================
+// End-to-end: +draft-create writing path emits envelope with lint fields.
+// =====================================================================
+
+// TestMailDraftCreate_WritePathLintEnvelopeDefault verifies +draft-create's
+// default envelope contains the three always-present hint/id fields
+// (compose_hint + draft_edit_hint + draft_id) and carries NO lint fields at
+// all — neither `*_count` nor the full Finding arrays.
+func TestMailDraftCreate_WritePathLintEnvelopeDefault(t *testing.T) {
+ f, stdout, _, reg := mailShortcutTestFactory(t)
+ chdirTemp(t)
+ registerMailboxProfileMock(reg)
+ registerDraftCreateOK(reg)
+
+ err := runMountedMailShortcut(t, MailDraftCreate, []string{
+ "+draft-create",
+ "--to", "alice@example.com",
+ "--subject", "Test",
+ "--body", `safe
red`,
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ data := decodeShortcutEnvelopeData(t, stdout)
+
+ // The three always-present hint/id fields must appear.
+ if hint, _ := data["compose_hint"].(string); hint == "" {
+ t.Error("compose_hint must be present in default envelope")
+ }
+ if hint, _ := data["draft_edit_hint"].(string); hint == "" {
+ t.Error("draft_edit_hint must be present in +draft-create default envelope")
+ } else if hint != draftEditHintConst {
+ t.Errorf("draft_edit_hint = %q, want exact const value", hint)
+ }
+ if id, _ := data["draft_id"].(string); id == "" {
+ t.Error("draft_id must be present in default envelope")
+ }
+
+ // No lint fields (neither count nor arrays) in default mode.
+ if _, present := data["lint_applied_count"]; present {
+ t.Error("lint_applied_count must NOT appear (count fields removed)")
+ }
+ if _, present := data["original_blocked_count"]; present {
+ t.Error("original_blocked_count must NOT appear (count fields removed)")
+ }
+ if _, present := data["lint_applied"]; present {
+ t.Error("lint_applied[] must be hidden in default mode")
+ }
+ if _, present := data["original_blocked"]; present {
+ t.Error("original_blocked[] must be hidden in default mode")
+ }
+}
+
+// TestMailDraftCreate_WritePathLintEnvelopeWithDetails verifies that passing
+// --show-lint-details attaches the two Finding arrays only — no `*_count`
+// fields — while still keeping compose_hint + draft_edit_hint + draft_id.
+func TestMailDraftCreate_WritePathLintEnvelopeWithDetails(t *testing.T) {
+ f, stdout, _, reg := mailShortcutTestFactory(t)
+ chdirTemp(t)
+ registerMailboxProfileMock(reg)
+ registerDraftCreateOK(reg)
+
+ err := runMountedMailShortcut(t, MailDraftCreate, []string{
+ "+draft-create",
+ "--to", "alice@example.com",
+ "--subject", "Test",
+ "--body", `safe
red`,
+ "--show-lint-details",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ data := decodeShortcutEnvelopeData(t, stdout)
+
+ // Always-present hint/id fields survive in detail mode.
+ if hint, _ := data["compose_hint"].(string); hint == "" {
+ t.Error("compose_hint must be present in detail envelope")
+ }
+ if hint, _ := data["draft_edit_hint"].(string); hint == "" {
+ t.Error("draft_edit_hint must be present in +draft-create detail envelope")
+ } else if hint != draftEditHintConst {
+ t.Errorf("draft_edit_hint = %q, want exact const value", hint)
+ }
+ if id, _ := data["draft_id"].(string); id == "" {
+ t.Error("draft_id must be present in detail envelope")
+ }
+
+ // `*_count` fields are gone — callers compute counts via len(arr).
+ if _, present := data["lint_applied_count"]; present {
+ t.Error("lint_applied_count must NOT appear (count fields removed)")
+ }
+ if _, present := data["original_blocked_count"]; present {
+ t.Error("original_blocked_count must NOT appear (count fields removed)")
+ }
+
+ la, ok := data["lint_applied"].([]interface{})
+ if !ok {
+ t.Fatalf("lint_applied missing or wrong type: %T", data["lint_applied"])
+ }
+ ob, ok := data["original_blocked"].([]interface{})
+ if !ok {
+ t.Fatalf("original_blocked missing or wrong type: %T", data["original_blocked"])
+ }
+ if len(la) < 1 {
+ t.Errorf("expected ≥1 lint_applied entry, got %d", len(la))
+ }
+ if len(ob) < 1 {
+ t.Errorf("expected ≥1 original_blocked entry, got %d", len(ob))
+ }
+}
+
+// TestMailDraftCreate_PlainTextWritePathOmitsLintFields verifies the
+// plain-text path's default envelope contains the always-present
+// compose_hint + draft_edit_hint + draft_id and emits no lint fields at all.
+func TestMailDraftCreate_PlainTextWritePathOmitsLintFields(t *testing.T) {
+ f, stdout, _, reg := mailShortcutTestFactory(t)
+ chdirTemp(t)
+ registerMailboxProfileMock(reg)
+ registerDraftCreateOK(reg)
+
+ err := runMountedMailShortcut(t, MailDraftCreate, []string{
+ "+draft-create",
+ "--to", "alice@example.com",
+ "--subject", "Test",
+ "--body", "plain text only",
+ "--plain-text",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ data := decodeShortcutEnvelopeData(t, stdout)
+
+ // Always-present hint/id fields on the plain-text branch.
+ if hint, _ := data["compose_hint"].(string); hint == "" {
+ t.Error("compose_hint must be present on plain-text path")
+ }
+ if hint, _ := data["draft_edit_hint"].(string); hint == "" {
+ t.Error("draft_edit_hint must be present on +draft-create plain-text path")
+ } else if hint != draftEditHintConst {
+ t.Errorf("draft_edit_hint = %q, want exact const value", hint)
+ }
+ if id, _ := data["draft_id"].(string); id == "" {
+ t.Error("draft_id must be present on plain-text path")
+ }
+
+ // No lint fields at all on the default plain-text path.
+ if _, present := data["lint_applied_count"]; present {
+ t.Error("lint_applied_count must NOT appear on plain-text default path")
+ }
+ if _, present := data["original_blocked_count"]; present {
+ t.Error("original_blocked_count must NOT appear on plain-text default path")
+ }
+ if _, present := data["lint_applied"]; present {
+ t.Error("lint_applied[] must be hidden in default mode (plain-text)")
+ }
+ if _, present := data["original_blocked"]; present {
+ t.Error("original_blocked[] must be hidden in default mode (plain-text)")
+ }
+}
+
+// TestMailDraftCreate_AutofixApplied verifies that the writing path actually
+// rewrites the body before sending it to drafts.create — the user's
+// tag must NOT reach the network as .
+func TestMailDraftCreate_AutofixApplied(t *testing.T) {
+ f, stdout, _, reg := mailShortcutTestFactory(t)
+ chdirTemp(t)
+ registerMailboxProfileMock(reg)
+ stub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/user_mailboxes/me/drafts",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{"draft_id": "d_test"},
+ },
+ }
+ reg.Register(stub)
+
+ err := runMountedMailShortcut(t, MailDraftCreate, []string{
+ "+draft-create",
+ "--to", "alice@example.com",
+ "--subject", "Test",
+ "--body", `x`,
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Decode the raw EML and confirm was rewritten before reaching
+ // emlbuilder. The base64url payload contains the HTML body in raw form.
+ captured := mustDecodeRawEMLFromStub(t, stub)
+ if strings.Contains(captured, ", EML still contains it: %q", captured)
+ }
+ if !strings.Contains(captured, " wrapper in EML, got %q", captured)
+ }
+}
+
+// TestMailDraftCreate_ScriptStrippedBeforeSend verifies after
`,
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ eml := mustDecodeRawEMLFromStub(t, stub)
+ if strings.Contains(eml, "正文
+```
+
+输出:
+
+```html
+正文
+```
+
+原因:`