Skip to content

Commit f7cb07a

Browse files
committed
feat(mail): return typed error envelopes across the mail domain
Replace every produced error path in shortcuts/mail with typed errs.* envelopes, so consumers get stable category, subtype, param/params, hint, retryable, and log_id metadata for classification and recovery instead of free-form message text. - Locally constructed mail errors move from output.Err* / output.Errorf / final fmt.Errorf / common legacy helpers to errs.* builders, with structured params on multi-flag validation and failed-precondition states kept non-retryable. - API-call failures move from runtime.CallAPI / DoAPIJSON legacy boundaries to runtime.CallAPITyped or runtime.ClassifyAPIResponse, and mail-specific enrichers read errs.ProblemOf so typed code, subtype, hint, and log_id metadata are preserved. - Batch draft-send partial failures now use runtime.OutPartialFailure so successful and failed draft sends stay in stdout while the command exits through a typed multi-status signal. - Add mail-domain typed helpers, mail API code metadata, and guard wiring to keep shortcuts/mail from reintroducing legacy envelopes or legacy API calls. - Keep genuine intermediate fmt.Errorf wraps in parser/builder layers annotated with nolint comments; command-facing paths wrap them into typed validation, API, network, or internal errors.
1 parent c000dc3 commit f7cb07a

55 files changed

Lines changed: 1549 additions & 658 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.golangci.yml

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -73,20 +73,20 @@ linters:
7373
- forbidigo
7474
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
7575
# Add a path when its migration is complete.
76-
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/)
76+
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/|shortcuts/mail/)
7777
text: errs-typed-only
7878
linters:
7979
- forbidigo
8080
# errs-no-bare-wrap enforced on paths fully migrated to typed final
8181
# errors. Scoped separately from errs-typed-only because cmd/auth/,
8282
# cmd/config/ still have residual fmt.Errorf and must not be caught.
83-
- path-except: (shortcuts/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
83+
- path-except: (shortcuts/drive/|shortcuts/mail/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
8484
text: errs-no-bare-wrap
8585
linters:
8686
- forbidigo
87-
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
88-
# still used by other domains until their later migration phase.
89-
- path-except: (shortcuts/drive/)
87+
# errs-no-legacy-helper is scoped to migrated domains: the shared helpers
88+
# it bans are still used by other domains until their later migration phase.
89+
- path-except: (shortcuts/drive/|shortcuts/mail/)
9090
text: errs-no-legacy-helper
9191
linters:
9292
- forbidigo
@@ -115,17 +115,17 @@ linters:
115115
msg: >-
116116
[errs-typed-only] use errs.NewXxxError(...) builder
117117
(see errs/types.go).
118-
# ── legacy shared error helpers banned on drive ──
118+
# ── legacy shared error helpers banned on migrated domains ──
119119
# These helpers internally produce legacy output.Err* shapes, so they
120-
# are invisible to the errs-typed-only ban above. Drive has migrated its
121-
# calls to typed errs.* (drive-local driveInputStatError / driveSaveError);
122-
# this prevents reintroduction. Other domains still use the shared
123-
# helpers (migrated globally in a later phase), so this is drive-scoped.
120+
# are invisible to the errs-typed-only ban above. Migrated domains use
121+
# typed errs.* builders or domain-local file-I/O helpers instead; this
122+
# prevents reintroduction while unmigrated domains continue to use the
123+
# shared helpers until their later migration phase.
124124
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
125125
msg: >-
126126
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
127-
shapes. Use the typed errs.NewXxxError builders or the drive-local
128-
driveInputStatError / driveSaveError helpers (shortcuts/drive/drive_errors.go).
127+
shapes. Use typed errs.NewXxxError builders or a domain-local
128+
file-I/O helper.
129129
# ── bare error wraps banned on fully-typed paths ──
130130
- pattern: (fmt\.Errorf|errors\.New)\b
131131
msg: >-

internal/errclass/classify.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@ func APIHint(subtype errs.Subtype) string {
244244
return "operate on source and target within the same tenant and region/unit"
245245
case errs.SubtypeCrossBrand:
246246
return "operate on source and target within the same brand environment"
247+
case errs.SubtypeQuotaExceeded:
248+
return "reduce the request volume or free quota, then retry after the relevant quota resets"
247249
}
248250
return ""
249251
}

internal/errclass/codemeta_mail.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package errclass
5+
6+
import "github.com/larksuite/cli/errs"
7+
8+
// mailCodeMeta holds mail-service Lark code -> CodeMeta mappings.
9+
// Only codes whose meaning is verifiable from repo evidence are registered;
10+
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
11+
var mailCodeMeta = map[int]CodeMeta{
12+
1234013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // mailbox not found or not active
13+
1236007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily send count exceeded
14+
1236008: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily external recipient count exceeded
15+
1236009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant daily external recipient count exceeded
16+
1236010: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // mail quota limit
17+
1236013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant storage limit exceeded
18+
}
19+
20+
func init() { mergeCodeMeta(mailCodeMeta, "mail") }

lint/errscontract/rule_no_legacy_common_helper_call.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
// common replacements or construct an errs.* typed error directly.
1717
var migratedCommonHelperPaths = []string{
1818
"shortcuts/drive/",
19+
"shortcuts/mail/",
1920
}
2021

2122
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"

lint/errscontract/rule_no_legacy_envelope_literal.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
// appending their path prefix here.
1818
var migratedEnvelopePaths = []string{
1919
"shortcuts/drive/",
20+
"shortcuts/mail/",
2021
}
2122

2223
// legacyOutputImportPath is the import path of the package that declares the

lint/errscontract/rules_test.go

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -894,27 +894,33 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
894894
"ResolveOpenIDs",
895895
"HandleApiResult",
896896
}
897-
for _, helper := range helpers {
898-
t.Run(helper, func(t *testing.T) {
899-
src := `package drive
897+
paths := []string{
898+
"shortcuts/drive/drive_search.go",
899+
"shortcuts/mail/mail_send.go",
900+
}
901+
for _, path := range paths {
902+
for _, helper := range helpers {
903+
t.Run(path+"_"+helper, func(t *testing.T) {
904+
src := `package migrated
900905
901906
import "github.com/larksuite/cli/shortcuts/common"
902907
903908
func boom() {
904-
common.` + helper + `()
909+
common.` + helper + `()
905910
}
906911
`
907-
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
908-
if len(v) != 1 {
909-
t.Fatalf("expected 1 violation for %s, got %d: %+v", helper, len(v), v)
910-
}
911-
if v[0].Action != ActionReject {
912-
t.Errorf("action = %q, want REJECT", v[0].Action)
913-
}
914-
if !strings.Contains(v[0].Message, "common."+helper) {
915-
t.Errorf("message should name helper %s: %s", helper, v[0].Message)
916-
}
917-
})
912+
v := CheckNoLegacyCommonHelperCall(path, src)
913+
if len(v) != 1 {
914+
t.Fatalf("expected 1 violation for %s on %s, got %d: %+v", helper, path, len(v), v)
915+
}
916+
if v[0].Action != ActionReject {
917+
t.Errorf("action = %q, want REJECT", v[0].Action)
918+
}
919+
if !strings.Contains(v[0].Message, "common."+helper) {
920+
t.Errorf("message should name helper %s: %s", helper, v[0].Message)
921+
}
922+
})
923+
}
918924
}
919925
}
920926

shortcuts/mail/body_file.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"strings"
99

1010
"github.com/larksuite/cli/extension/fileio"
11-
"github.com/larksuite/cli/internal/output"
1211
"github.com/larksuite/cli/shortcuts/common"
1312
)
1413

@@ -51,11 +50,15 @@ const maxBodyFileSize = 32 * 1024 * 1024 // 32 MB
5150
func validateBodyFileMutex(bodyFlag, bodyFile string, validatePath func(string) error) error {
5251
bodyEmpty := strings.TrimSpace(bodyFlag) == ""
5352
if !bodyEmpty && bodyFile != "" {
54-
return output.ErrValidation("--body and --body-file are mutually exclusive; pass exactly one")
53+
return mailValidationError("--body and --body-file are mutually exclusive; pass exactly one").
54+
WithParams(
55+
mailInvalidParam("--body", "mutually exclusive with --body-file"),
56+
mailInvalidParam("--body-file", "mutually exclusive with --body"),
57+
)
5558
}
5659
if bodyFile != "" {
5760
if err := validatePath(bodyFile); err != nil {
58-
return output.ErrValidation("--body-file: %v", err)
61+
return mailValidationParamError("--body-file", "--body-file: %v", err).WithCause(err)
5962
}
6063
}
6164
return nil
@@ -79,7 +82,7 @@ func resolveBodyFromFlags(runtime *common.RuntimeContext) (string, error) {
7982

8083
func validateRequiredResolvedBody(body string, hasTemplate bool, message string) error {
8184
if !hasTemplate && strings.TrimSpace(body) == "" {
82-
return output.ErrValidation(message)
85+
return mailValidationError("%s", message)
8386
}
8487
return nil
8588
}
@@ -95,15 +98,15 @@ func validateRequiredResolvedBody(body string, hasTemplate bool, message string)
9598
func readBodyFile(fio fileio.FileIO, path string) (string, error) {
9699
f, err := fio.Open(path)
97100
if err != nil {
98-
return "", output.ErrValidation("open --body-file %s: %v", path, err)
101+
return "", mailValidationParamError("--body-file", "open --body-file %s: %v", path, err).WithCause(mailInputStatError(err))
99102
}
100103
defer f.Close()
101104
buf, err := io.ReadAll(io.LimitReader(f, maxBodyFileSize+1))
102105
if err != nil {
103-
return "", output.ErrValidation("read --body-file %s: %v", path, err)
106+
return "", mailValidationParamError("--body-file", "read --body-file %s: %v", path, err).WithCause(err)
104107
}
105108
if len(buf) > maxBodyFileSize {
106-
return "", output.ErrValidation("--body-file: file exceeds %d MB limit", maxBodyFileSize/1024/1024)
109+
return "", mailValidationParamError("--body-file", "--body-file: file exceeds %d MB limit", maxBodyFileSize/1024/1024)
107110
}
108111
return string(buf), nil
109112
}

shortcuts/mail/draft/charset.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func encodeTextCharset(body []byte, label string) ([]byte, error) {
4949
}
5050
enc, _ := htmlcharset.Lookup(label)
5151
if enc == nil {
52-
return nil, fmt.Errorf("unsupported charset %q", label)
52+
return nil, fmt.Errorf("unsupported charset %q", label) //nolint:forbidigo // intermediate draft charset error; mail command layer wraps into typed ValidationError.
5353
}
5454
var buf bytes.Buffer
5555
writer := transform.NewWriter(&buf, enc.NewEncoder())

shortcuts/mail/draft/large_attachment_parse.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
22
// SPDX-License-Identifier: MIT
33

4+
//nolint:forbidigo // intermediate draft large-attachment parser errors; mail command layer wraps into typed ValidationError.
45
package draft
56

67
import (

shortcuts/mail/draft/limits.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
22
// SPDX-License-Identifier: MIT
33

4+
//nolint:forbidigo // intermediate draft attachment limit errors; mail command layer wraps into typed ValidationError.
45
package draft
56

67
import (

0 commit comments

Comments
 (0)