diff --git a/shortcuts/mail/body_file.go b/shortcuts/mail/body_file.go new file mode 100644 index 000000000..f15c05ab2 --- /dev/null +++ b/shortcuts/mail/body_file.go @@ -0,0 +1,109 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "io" + "strings" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// bodyFileFlag is the shared `--body-file` flag declaration reused by every +// compose shortcut (+send / +draft-create / +reply / +reply-all / +forward). +// All six shortcuts honour the same mutual-exclusion contract with `--body` +// and the cwd-subtree path safety rule. The flag is intentionally NOT +// shared with `+lint-html` because that command's description differs +// ("HTML to lint" vs "email body") in a way that is more readable when +// authored per-shortcut. `+draft-edit` does not expose `--body-file` either +// — its body ops flow through `--patch-file` JSON whose `value` field is +// the natural file-based entry point for large bodies. +var bodyFileFlag = common.Flag{ + Name: "body-file", + Desc: "Path (relative, within cwd subtree) to a file containing the email body HTML. Mutually exclusive with --body. Size capped at 32 MB.", + Input: []string{common.File}, +} + +// maxBodyFileSize caps the size of a `--body-file` HTML input. The compose +// path's downstream EML limit is 25 MB (helpers.go MAX_EML_BYTES); we allow a +// bit more headroom here (32 MB) so a body close to the limit still loads +// before the downstream check fires with a clearer error message. The cap +// prevents an `io.ReadAll` from blowing memory on a misdirected gigabyte +// file. +const maxBodyFileSize = 32 * 1024 * 1024 // 32 MB + +// validateBodyFileMutex enforces the `--body` / `--body-file` mutual +// exclusion + cwd-subtree path safety. Compose shortcuts call this in +// their Validate phase so AI / users see a clear error before any work +// runs. Pass the shortcut's RuntimeContext-resolved flag values directly: +// `bodyFlag` is the `--body` value (may be empty), `bodyFile` is the +// trimmed `--body-file` value, and `validatePath` is the +// runtime.ValidatePath bound function used to enforce the relative-path +// rule (cwd-subtree only; no absolute / `..` traversal). +// +// Returns an ErrValidation error when either invariant is violated, nil +// otherwise. The "exactly one of {--body, --body-file}" check is +// shortcut-specific (some shortcuts allow neither, e.g. `+forward` with +// no explicit body) and is therefore left to the caller. +func validateBodyFileMutex(bodyFlag, bodyFile string, validatePath func(string) error) error { + bodyEmpty := strings.TrimSpace(bodyFlag) == "" + if !bodyEmpty && bodyFile != "" { + return output.ErrValidation("--body and --body-file are mutually exclusive; pass exactly one") + } + if bodyFile != "" { + if err := validatePath(bodyFile); err != nil { + return output.ErrValidation("--body-file: %v", err) + } + } + return nil +} + +// resolveBodyFromFlags returns the body content from --body or --body-file. +// Validate has already enforced mutual exclusion via validateBodyFileMutex, +// so exactly one is set (or neither when a template / parent message +// supplies the body). Returns ("", nil) when neither flag is set so +// downstream code can decide whether the empty body is allowed. +func resolveBodyFromFlags(runtime *common.RuntimeContext) (string, error) { + if body := runtime.Str("body"); strings.TrimSpace(body) != "" { + return body, nil + } + path := strings.TrimSpace(runtime.Str("body-file")) + if path == "" { + return "", nil + } + return readBodyFile(runtime.FileIO(), path) +} + +func validateRequiredResolvedBody(body string, hasTemplate bool, message string) error { + if !hasTemplate && strings.TrimSpace(body) == "" { + return output.ErrValidation(message) + } + return nil +} + +// readBodyFile loads --body-file content with a size cap. Returns an +// ErrValidation error if the file exceeds maxBodyFileSize or any IO error +// occurs. The size check uses io.LimitReader(maxBodyFileSize+1) so any +// over-cap byte is observable without reading the whole file. +// +// Callers MUST have run runtime.ValidatePath(path) on `path` first — the +// helper only opens the file via the supplied FileIO and does not repeat +// the cwd-subtree safety check. +func readBodyFile(fio fileio.FileIO, path string) (string, error) { + f, err := fio.Open(path) + if err != nil { + return "", output.ErrValidation("open --body-file %s: %v", path, err) + } + defer f.Close() + buf, err := io.ReadAll(io.LimitReader(f, maxBodyFileSize+1)) + if err != nil { + return "", output.ErrValidation("read --body-file %s: %v", path, err) + } + if len(buf) > maxBodyFileSize { + return "", output.ErrValidation("--body-file: file exceeds %d MB limit", maxBodyFileSize/1024/1024) + } + return string(buf), nil +} diff --git a/shortcuts/mail/lint/linter.go b/shortcuts/mail/lint/linter.go new file mode 100644 index 000000000..d0286a4f0 --- /dev/null +++ b/shortcuts/mail/lint/linter.go @@ -0,0 +1,1156 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lint + +import ( + "bytes" + "fmt" + "hash/fnv" + "strings" + + xhtml "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +// MaxExcerptBytes caps the raw-HTML excerpt embedded in a Finding.Excerpt so +// a single offending tag with megabyte content can't bloat the envelope JSON. +// Lint operates on bytes only, but the excerpt representation must not be +// size-amplifying. +const MaxExcerptBytes = 200 + +// Run lints the given HTML body and returns a structured Report. +// Report.CleanedHTML contains the rewritten HTML (warnings rewritten + errors +// deleted) — the autofix is unconditional. +// +// IMPORTANT: when the input is empty or plain-text (no HTML markup detected +// by the cli's existing `bodyIsHTML` heuristic), callers should short-circuit +// with EmptyReport(html) instead of paying the parse cost. Run still handles +// this gracefully — html.Parse on plain text wraps the input in +//
..., and the lib's pass-through +// rendering will reproduce the original text — but the round-trip is wasteful +// and produces no findings. +func Run(html string, opts Options) Report { + if html == "" { + return EmptyReport("") + } + + rep := Report{ + Applied: []Finding{}, + Blocked: []Finding{}, + } + + // We use html.ParseFragment so users authoring fragment-style snippets + // (the canonical compose-5 input shape — `