Skip to content

Commit 45af782

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 abc0553 commit 45af782

57 files changed

Lines changed: 2170 additions & 665 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/common/drive_media_upload.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
1616

1717
"github.com/larksuite/cli/errs"
18+
"github.com/larksuite/cli/internal/client"
1819
"github.com/larksuite/cli/internal/output"
1920
)
2021

@@ -57,6 +58,7 @@ type DriveMediaMultipartUploadConfig struct {
5758
Reader io.Reader
5859
}
5960

61+
// Deprecated: use UploadDriveMediaAllTyped for typed error envelopes.
6062
func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
6163
var fileReader io.Reader
6264
if cfg.Reader != nil {
@@ -98,6 +100,52 @@ func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig)
98100
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadAllAction)
99101
}
100102

103+
// UploadDriveMediaAllTyped is the typed-error counterpart of
104+
// UploadDriveMediaAll: file-open failures surface as typed validation errors,
105+
// transport failures as typed network errors, and API failures are classified
106+
// via ClassifyAPIResponse so subtype / code / log_id survive on the error.
107+
func UploadDriveMediaAllTyped(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
108+
var fileReader io.Reader
109+
if cfg.Reader != nil {
110+
fileReader = cfg.Reader
111+
} else {
112+
f, err := runtime.FileIO().Open(cfg.FilePath)
113+
if err != nil {
114+
return "", WrapInputStatErrorTyped(err)
115+
}
116+
defer f.Close()
117+
fileReader = f
118+
}
119+
120+
fd := larkcore.NewFormdata()
121+
fd.AddField("file_name", cfg.FileName)
122+
fd.AddField("parent_type", cfg.ParentType)
123+
fd.AddField("size", fmt.Sprintf("%d", cfg.FileSize))
124+
if cfg.ParentNode != nil {
125+
fd.AddField("parent_node", *cfg.ParentNode)
126+
}
127+
if cfg.Extra != "" {
128+
fd.AddField("extra", cfg.Extra)
129+
}
130+
fd.AddFile("file", fileReader)
131+
132+
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
133+
HttpMethod: http.MethodPost,
134+
ApiPath: "/open-apis/drive/v1/medias/upload_all",
135+
Body: fd,
136+
}, larkcore.WithFileUpload())
137+
if err != nil {
138+
return "", prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadAllAction)
139+
}
140+
141+
data, err := runtime.ClassifyAPIResponse(apiResp)
142+
if err != nil {
143+
return "", prefixDriveMediaUploadProblem(err, driveMediaUploadAllAction)
144+
}
145+
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadAllAction)
146+
}
147+
148+
// Deprecated: use UploadDriveMediaMultipartTyped for typed error envelopes.
101149
func UploadDriveMediaMultipart(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
102150
// upload_prepare expects parent_node to be present even when the caller wants
103151
// the service default/root behavior, so multipart callers pass an explicit
@@ -130,6 +178,43 @@ func UploadDriveMediaMultipart(runtime *RuntimeContext, cfg DriveMediaMultipartU
130178
return finishDriveMediaMultipartUpload(runtime, session.UploadID, session.BlockNum)
131179
}
132180

181+
// UploadDriveMediaMultipartTyped is the typed-error counterpart of
182+
// UploadDriveMediaMultipart: prepare/finish failures come back typed from
183+
// CallAPITyped, malformed session plans surface as invalid-response internal
184+
// errors, and per-part transport/API failures are classified the same way as
185+
// UploadDriveMediaAllTyped.
186+
func UploadDriveMediaMultipartTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
187+
// upload_prepare expects parent_node to be present even when the caller wants
188+
// the service default/root behavior, so multipart callers pass an explicit
189+
// string instead of relying on field omission like upload_all does.
190+
prepareBody := map[string]interface{}{
191+
"file_name": cfg.FileName,
192+
"parent_type": cfg.ParentType,
193+
"parent_node": cfg.ParentNode,
194+
"size": cfg.FileSize,
195+
}
196+
if cfg.Extra != "" {
197+
prepareBody["extra"] = cfg.Extra
198+
}
199+
200+
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, prepareBody)
201+
if err != nil {
202+
return "", err
203+
}
204+
205+
session, err := parseDriveMediaMultipartUploadSessionTyped(data)
206+
if err != nil {
207+
return "", err
208+
}
209+
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize))
210+
211+
if err = uploadDriveMediaMultipartPartsTyped(runtime, cfg, session); err != nil {
212+
return "", err
213+
}
214+
215+
return finishDriveMediaMultipartUploadTyped(runtime, session.UploadID, session.BlockNum)
216+
}
217+
133218
func ParseDriveMediaMultipartUploadSession(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
134219
// The backend chooses both chunk size and chunk count. Validate them once so
135220
// the streaming loop can follow the returned plan without re-checking shape.
@@ -280,3 +365,122 @@ func finishDriveMediaMultipartUpload(runtime *RuntimeContext, uploadID string, b
280365
}
281366
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadFinishAction)
282367
}
368+
369+
// prefixDriveMediaUploadProblem prepends the upload action to a typed error's
370+
// message so callers see which upload step failed. Non-typed errors are
371+
// returned unchanged.
372+
func prefixDriveMediaUploadProblem(err error, action string) error {
373+
if p, ok := errs.ProblemOf(err); ok {
374+
p.Message = action + ": " + p.Message
375+
}
376+
return err
377+
}
378+
379+
// parseDriveMediaMultipartUploadSessionTyped validates the upload_prepare
380+
// session plan like ParseDriveMediaMultipartUploadSession, but reports a
381+
// malformed plan as a typed invalid-response internal error.
382+
func parseDriveMediaMultipartUploadSessionTyped(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
383+
session := DriveMediaMultipartUploadSession{
384+
UploadID: GetString(data, "upload_id"),
385+
BlockSize: int64(GetFloat(data, "block_size")),
386+
BlockNum: int(GetFloat(data, "block_num")),
387+
}
388+
if session.UploadID == "" {
389+
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: no upload_id returned")
390+
}
391+
if session.BlockSize <= 0 {
392+
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
393+
}
394+
if session.BlockNum <= 0 {
395+
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_num returned")
396+
}
397+
return session, nil
398+
}
399+
400+
// extractDriveMediaUploadFileTokenTyped mirrors ExtractDriveMediaUploadFileToken
401+
// with a typed invalid-response internal error for a missing file_token.
402+
func extractDriveMediaUploadFileTokenTyped(data map[string]interface{}, action string) (string, error) {
403+
fileToken := GetString(data, "file_token")
404+
if fileToken == "" {
405+
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: no file_token returned", action)
406+
}
407+
return fileToken, nil
408+
}
409+
410+
// uploadDriveMediaMultipartPartsTyped mirrors uploadDriveMediaMultipartParts
411+
// with typed errors for file-open, file-read, and per-part upload failures.
412+
func uploadDriveMediaMultipartPartsTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig, session DriveMediaMultipartUploadSession) error {
413+
var r io.Reader
414+
if cfg.Reader != nil {
415+
r = cfg.Reader
416+
} else {
417+
f, err := runtime.FileIO().Open(cfg.FilePath)
418+
if err != nil {
419+
return WrapInputStatErrorTyped(err)
420+
}
421+
defer f.Close()
422+
r = f
423+
}
424+
425+
maxInt := int64(^uint(0) >> 1)
426+
bufferSize := session.BlockSize
427+
if bufferSize <= 0 || bufferSize > maxInt {
428+
return errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
429+
}
430+
buffer := make([]byte, int(bufferSize))
431+
remaining := cfg.FileSize
432+
// Follow the server-declared block plan exactly; upload_finish expects the
433+
// same block count returned by upload_prepare.
434+
for seq := 0; seq < session.BlockNum; seq++ {
435+
chunkSize := session.BlockSize
436+
if remaining > 0 && chunkSize > remaining {
437+
chunkSize = remaining
438+
}
439+
440+
n, readErr := io.ReadFull(r, buffer[:int(chunkSize)])
441+
if readErr != nil {
442+
return WrapInputStatErrorTyped(readErr)
443+
}
444+
445+
if err := uploadDriveMediaMultipartPartTyped(runtime, session.UploadID, seq, buffer[:n]); err != nil {
446+
return err
447+
}
448+
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, FormatSize(int64(n)))
449+
remaining -= int64(n)
450+
}
451+
452+
return nil
453+
}
454+
455+
func uploadDriveMediaMultipartPartTyped(runtime *RuntimeContext, uploadID string, seq int, chunk []byte) error {
456+
fd := larkcore.NewFormdata()
457+
fd.AddField("upload_id", uploadID)
458+
fd.AddField("seq", fmt.Sprintf("%d", seq))
459+
fd.AddField("size", fmt.Sprintf("%d", len(chunk)))
460+
fd.AddFile("file", bytes.NewReader(chunk))
461+
462+
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
463+
HttpMethod: http.MethodPost,
464+
ApiPath: "/open-apis/drive/v1/medias/upload_part",
465+
Body: fd,
466+
}, larkcore.WithFileUpload())
467+
if err != nil {
468+
return prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadPartAction)
469+
}
470+
471+
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
472+
return prefixDriveMediaUploadProblem(err, driveMediaUploadPartAction)
473+
}
474+
return nil
475+
}
476+
477+
func finishDriveMediaMultipartUploadTyped(runtime *RuntimeContext, uploadID string, blockNum int) (string, error) {
478+
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{
479+
"upload_id": uploadID,
480+
"block_num": blockNum,
481+
})
482+
if err != nil {
483+
return "", err
484+
}
485+
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadFinishAction)
486+
}

0 commit comments

Comments
 (0)