Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions shortcuts/mail/body_file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// 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")

Check warning on line 54 in shortcuts/mail/body_file.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/body_file.go#L54

Added line #L54 was not covered by tests
}
if bodyFile != "" {
if err := validatePath(bodyFile); err != nil {
return output.ErrValidation("--body-file: %v", err)

Check warning on line 58 in shortcuts/mail/body_file.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/body_file.go#L57-L58

Added lines #L57 - L58 were not covered by tests
}
}
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)

Check warning on line 77 in shortcuts/mail/body_file.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/body_file.go#L77

Added line #L77 was not covered by tests
}

// 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)

Check warning on line 91 in shortcuts/mail/body_file.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/body_file.go#L91

Added line #L91 was not covered by tests
}
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)

Check warning on line 96 in shortcuts/mail/body_file.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/body_file.go#L96

Added line #L96 was not covered by tests
}
if len(buf) > maxBodyFileSize {
return "", output.ErrValidation("--body-file: file exceeds %d MB limit", maxBodyFileSize/1024/1024)

Check warning on line 99 in shortcuts/mail/body_file.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/body_file.go#L99

Added line #L99 was not covered by tests
}
return string(buf), nil
}
Loading
Loading