mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
7 Commits
docs/lark-
...
v1.0.54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
297b2a222e | ||
|
|
80a5f30f4d | ||
|
|
cf35d1e499 | ||
|
|
fd16cf106b | ||
|
|
53076733ec | ||
|
|
a3bee13ca9 | ||
|
|
6217bd2c29 |
27
CHANGELOG.md
27
CHANGELOG.md
@@ -2,6 +2,32 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.54] - 2026-06-15
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Auto-attach default signature on send/reply/forward (#1415)
|
||||
- **drive**: Support `original_creator_ids` filter in search (#1046)
|
||||
- **cli**: Simplify proxy plugin warning and gate it on TTY (#1448)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **doc**: Fix docs fetch and update ergonomics (#1466)
|
||||
- **vfs**: Reject blank local paths (#1460)
|
||||
- **vfs**: Reject Windows absolute paths cross-platform (#1401)
|
||||
- **event**: Clarify remote bus blocker recovery (#1454)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Converge command pipelines onto a typed metadata model + catalog (#1191)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Document `@mention` format per message type (text/post/card) (#1419)
|
||||
- **doc**: Clarify lark-doc create title guidance (#1474)
|
||||
- **skills**: Add rename prompt for import without `--name` (#1461)
|
||||
- **apps**: Drop Miaoda brand word from apps command help text (#1399)
|
||||
|
||||
## [v1.0.53] - 2026-06-12
|
||||
|
||||
### Features
|
||||
@@ -1149,6 +1175,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
|
||||
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
|
||||
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
|
||||
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
|
||||
|
||||
@@ -53,8 +53,8 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
fmt.Fprintf(errOut, "[event] remote connection check: online_instance_cnt=%d\n", count)
|
||||
if count > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"another event bus is already connected to this app (%d active connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
|
||||
WithHint("use `lark-cli event status` to check, or `lark-cli event stop` on the other machine first")
|
||||
"another event bus is already connected to this app (%d remote event connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
|
||||
WithHint("remote event connection detected; `lark-cli event status` and `lark-cli event stop` only inspect local buses; stop the owner host/process, wait for the platform connection timeout, or use a separate app/profile")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -50,8 +50,16 @@ func TestEnsureBus_RemoteBusAlreadyConnectedIsFailedPrecondition(t *testing.T) {
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "event stop") {
|
||||
t.Errorf("hint should point at `event stop`, got: %q", ve.Hint)
|
||||
wantHints := []string{
|
||||
"remote event connection",
|
||||
"`lark-cli event status` and `lark-cli event stop` only inspect local buses",
|
||||
"stop the owner host/process",
|
||||
"wait for the platform connection timeout",
|
||||
}
|
||||
for _, want := range wantHints {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint missing %q\ngot: %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
|
||||
{"unicode normal", "报告.xlsx", false},
|
||||
{"dot-dot resolves to cwd", "subdir/..", false},
|
||||
|
||||
// ── GIVEN: empty or blank paths → THEN: rejected ──
|
||||
{"empty path", "", true},
|
||||
{"blank path", " ", true},
|
||||
|
||||
// ── GIVEN: path traversal via .. → THEN: rejected ──
|
||||
{"dot-dot escape", "../../.ssh/authorized_keys", true},
|
||||
{"dot-dot mid path", "subdir/../../etc/passwd", true},
|
||||
|
||||
@@ -60,6 +60,10 @@ func safePath(raw, flagName string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return "", fmt.Errorf("%s must not be empty", flagName)
|
||||
}
|
||||
|
||||
if isAbsolutePath(raw) {
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
|
||||
{"unicode normal", "报告.xlsx", false},
|
||||
{"dot-dot resolves to cwd", "subdir/..", false},
|
||||
|
||||
// ── GIVEN: empty or blank paths → THEN: rejected ──
|
||||
{"empty path", "", true},
|
||||
{"blank path", " ", true},
|
||||
|
||||
// ── GIVEN: path traversal via .. → THEN: rejected ──
|
||||
{"dot-dot escape", "../../.ssh/authorized_keys", true},
|
||||
{"dot-dot mid path", "subdir/../../etc/passwd", true},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.53",
|
||||
"version": "1.0.54",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -38,9 +38,6 @@ func validateFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc: %v", err).WithParam("--doc")
|
||||
}
|
||||
if err := validateFetchDetail(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateReadModeFlags(runtime); err != nil {
|
||||
@@ -71,6 +68,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
|
||||
}
|
||||
|
||||
runtime.OutFormatRaw(data, nil, func(w io.Writer) {
|
||||
if doc, ok := data["document"].(map[string]interface{}); ok {
|
||||
@@ -90,7 +90,7 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body["revision_id"] = v
|
||||
}
|
||||
|
||||
detail := runtime.Str("detail")
|
||||
detail := effectiveFetchDetail(runtime)
|
||||
switch detail {
|
||||
case "", "simple":
|
||||
body["export_option"] = map[string]interface{}{
|
||||
@@ -146,17 +146,33 @@ func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
return ro
|
||||
}
|
||||
|
||||
// validateFetchDetail 非 xml 格式(markdown)不承载 block_id 与样式属性,拒绝 with-ids/full。
|
||||
func validateFetchDetail(runtime *common.RuntimeContext) error {
|
||||
// effectiveFetchDetail degrades detail options that cannot be represented by
|
||||
// non-XML exports. The original flag value is left intact so callers can still
|
||||
// surface an explicit warning in execute output.
|
||||
func effectiveFetchDetail(runtime *common.RuntimeContext) string {
|
||||
format := strings.TrimSpace(runtime.Str("doc-format"))
|
||||
detail := strings.TrimSpace(runtime.Str("detail"))
|
||||
if format == "" || format == "xml" {
|
||||
return nil
|
||||
return detail
|
||||
}
|
||||
if detail == "with-ids" || detail == "full" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--detail %s is only supported with --doc-format xml; %s output has no block ids, use --detail simple or switch to --doc-format xml", detail, format).WithParam("--detail")
|
||||
return "simple"
|
||||
}
|
||||
return nil
|
||||
return detail
|
||||
}
|
||||
|
||||
func addFetchDetailDowngradeWarning(runtime *common.RuntimeContext, data map[string]interface{}) string {
|
||||
format := strings.TrimSpace(runtime.Str("doc-format"))
|
||||
detail := strings.TrimSpace(runtime.Str("detail"))
|
||||
if format == "" || format == "xml" {
|
||||
return ""
|
||||
}
|
||||
if detail != "with-ids" && detail != "full" {
|
||||
return ""
|
||||
}
|
||||
warning := fmt.Sprintf("--detail %s is only supported with --doc-format xml; returning %s output and ignoring the unsupported detail option", detail, format)
|
||||
appendDocWarning(data, warning)
|
||||
return warning
|
||||
}
|
||||
|
||||
// validateReadModeFlags 客户端前置校验,服务端也会再校验一次。
|
||||
|
||||
@@ -5,9 +5,12 @@ package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -96,6 +99,126 @@ func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, detail := range []string{"with-ids", "full"} {
|
||||
t.Run(detail, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
|
||||
"doc-format": "markdown",
|
||||
"detail": detail,
|
||||
})
|
||||
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("validateFetchV2() error = %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
|
||||
exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{})
|
||||
if exportOption == nil {
|
||||
t.Fatalf("missing export_option: %#v", dry.API[0].Body)
|
||||
}
|
||||
if got := exportOption["export_block_id"]; got != false {
|
||||
t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_style_attrs"]; got != false {
|
||||
t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_cite_extra_data"]; got != false {
|
||||
t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchMarkdownDetailDowngradeWarnsInOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-detail-warning"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchWarning/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnFetchWarning",
|
||||
"revision_id": float64(1),
|
||||
"content": "# hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--doc", "doxcnFetchWarning",
|
||||
"--doc-format", "markdown",
|
||||
"--detail", "with-ids",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
warnings, _ := data["warnings"].([]interface{})
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("warnings = %#v, want one downgrade warning", data["warnings"])
|
||||
}
|
||||
if got, _ := warnings[0].(string); !strings.Contains(got, "returning markdown output") || !strings.Contains(got, "ignoring the unsupported detail option") {
|
||||
t.Fatalf("unexpected warning: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchMarkdownDetailDowngradeWarnsInPrettyOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-detail-pretty-warning"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchPrettyWarning/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnFetchPrettyWarning",
|
||||
"revision_id": float64(1),
|
||||
"content": "# hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--doc", "doxcnFetchPrettyWarning",
|
||||
"--doc-format", "markdown",
|
||||
"--detail", "full",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got := stdout.String(); got != "# hello\n" {
|
||||
t.Fatalf("stdout = %q, want markdown content only", got)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "warning: --detail full is only supported with --doc-format xml") ||
|
||||
!strings.Contains(got, "returning markdown output") ||
|
||||
!strings.Contains(got, "ignoring the unsupported detail option") {
|
||||
t.Fatalf("stderr missing downgrade warning: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -91,3 +91,22 @@ func buildDriveRouteExtra(docID string) (string, error) {
|
||||
}
|
||||
return string(extra), nil
|
||||
}
|
||||
|
||||
func appendDocWarning(data map[string]interface{}, warning string) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(warning) == "" {
|
||||
return
|
||||
}
|
||||
switch existing := data["warnings"].(type) {
|
||||
case []interface{}:
|
||||
data["warnings"] = append(existing, warning)
|
||||
case []string:
|
||||
data["warnings"] = append(existing, warning)
|
||||
case nil:
|
||||
data["warnings"] = []string{warning}
|
||||
default:
|
||||
data["warnings"] = []interface{}{existing, warning}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -88,3 +89,51 @@ func TestBuildDriveRouteExtraEscapesJSON(t *testing.T) {
|
||||
t.Fatalf("buildDriveRouteExtra() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDocWarning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
appendDocWarning(nil, "ignored")
|
||||
|
||||
empty := map[string]interface{}{}
|
||||
appendDocWarning(empty, " ")
|
||||
if _, ok := empty["warnings"]; ok {
|
||||
t.Fatalf("blank warning should be ignored: %#v", empty)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]interface{}
|
||||
want interface{}
|
||||
}{
|
||||
{
|
||||
name: "missing warnings",
|
||||
data: map[string]interface{}{},
|
||||
want: []string{"new warning"},
|
||||
},
|
||||
{
|
||||
name: "string slice warnings",
|
||||
data: map[string]interface{}{"warnings": []string{"old warning"}},
|
||||
want: []string{"old warning", "new warning"},
|
||||
},
|
||||
{
|
||||
name: "interface slice warnings",
|
||||
data: map[string]interface{}{"warnings": []interface{}{"old warning"}},
|
||||
want: []interface{}{"old warning", "new warning"},
|
||||
},
|
||||
{
|
||||
name: "scalar warning",
|
||||
data: map[string]interface{}{"warnings": "old warning"},
|
||||
want: []interface{}{"old warning", "new warning"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
appendDocWarning(tt.data, "new warning")
|
||||
if got := tt.data["warnings"]; !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("warnings = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,13 @@ func isHTMLBlockBoundary(n *xhtml.Node) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// PlainTextFromHTML is the exported wrapper over plainTextFromHTML, so the
|
||||
// mail package can render an HTML signature as a plain-text fallback when a
|
||||
// message body is sent in plain-text mode. The conversion logic is unchanged.
|
||||
func PlainTextFromHTML(raw string) string {
|
||||
return plainTextFromHTML(raw)
|
||||
}
|
||||
|
||||
// bodyLooksLikeHTML reports whether raw appears to contain HTML markup.
|
||||
// This is intentionally heuristic: it exists to reject obvious plain-text
|
||||
// input when a draft's authored body is text/html.
|
||||
|
||||
@@ -102,3 +102,10 @@ func TestIsHTMLNonTextTag(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainTextFromHTMLExported(t *testing.T) {
|
||||
got := PlainTextFromHTML("<p>Hello world</p>")
|
||||
if !strings.Contains(got, "Hello world") {
|
||||
t.Fatalf("PlainTextFromHTML: expected to contain \"Hello world\", got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ var MailDraftCreate = common.Shortcut{
|
||||
{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed."},
|
||||
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's subject/body/to/cc/bcc/attachments are merged with user-supplied flags (user flags win). Requires --as user."},
|
||||
signatureFlag,
|
||||
noSignatureFlag,
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
|
||||
showLintDetailsFlag,
|
||||
@@ -92,7 +93,7 @@ var MailDraftCreate = common.Shortcut{
|
||||
if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" {
|
||||
return mailValidationParamError("--subject", "--subject is required; pass the final email subject (or use --template-id)")
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEventFlags(runtime); err != nil {
|
||||
@@ -180,12 +181,22 @@ var MailDraftCreate = common.Shortcut{
|
||||
if strings.TrimSpace(input.Body) == "" {
|
||||
return mailValidationParamError("--body", "effective body is empty after applying template; pass --body explicitly")
|
||||
}
|
||||
sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from"))
|
||||
signatureID := runtime.Str("signature-id")
|
||||
noSignature := runtime.Bool("no-signature")
|
||||
senderEmail := resolveComposeSenderEmail(runtime)
|
||||
// Auto-resolve default signature when neither --no-signature nor --signature-id is set.
|
||||
if noSignature {
|
||||
signatureID = ""
|
||||
} else if signatureID == "" {
|
||||
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, false)
|
||||
}
|
||||
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
|
||||
runtime.Str("signature-id") != "", !input.PlainText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawEML, lintApplied, lintBlocked, err := buildRawEMLForDraftCreate(ctx, runtime, input, sigResult, priority,
|
||||
templateLargeAttachmentIDs, mailboxID, templateID, templateInlineAttachments, templateSmallAttachments)
|
||||
templateLargeAttachmentIDs, mailboxID, templateID, templateInlineAttachments, templateSmallAttachments, senderEmail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -241,13 +252,19 @@ func buildRawEMLForDraftCreate(
|
||||
mailboxID, templateID string,
|
||||
templateInlineAttachments []templateInlineRef,
|
||||
templateSmallAttachments []templateAttachmentRef,
|
||||
senderEmailHint string,
|
||||
) (rawEMLOut string, lintApplied, lintBlocked []lint.Finding, err error) {
|
||||
// Initialise lint findings as empty (non-nil) slices so callers can
|
||||
// surface them through the envelope unconditionally even on the
|
||||
// plain-text branch.
|
||||
lintApplied, lintBlocked = emptyLintFindings()
|
||||
|
||||
senderEmail := resolveComposeSenderEmail(runtime)
|
||||
// Use the pre-resolved senderEmail when available (avoids a duplicate
|
||||
// profile API call when Execute already fetched it for auto-resolve).
|
||||
senderEmail := senderEmailHint
|
||||
if senderEmail == "" {
|
||||
senderEmail = resolveComposeSenderEmail(runtime)
|
||||
}
|
||||
if senderEmail == "" {
|
||||
return "", lintApplied, lintBlocked, mailValidationParamError("--from", "unable to determine sender email; please specify --from explicitly")
|
||||
}
|
||||
@@ -290,7 +307,7 @@ func buildRawEMLForDraftCreate(
|
||||
var composedHTMLBody string
|
||||
var composedTextBody string
|
||||
if input.PlainText {
|
||||
composedTextBody = input.Body
|
||||
composedTextBody = injectPlainTextSignature(input.Body, sigResult)
|
||||
bld = bld.TextBody([]byte(composedTextBody))
|
||||
} else if bodyIsHTML(input.Body) || sigResult != nil {
|
||||
htmlBody := input.Body
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestBuildRawEMLForDraftCreate_ResolvesLocalImages(t *testing.T) {
|
||||
Body: `<p>Hello</p><p><img src="./test_image.png" /></p>`,
|
||||
}
|
||||
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func TestBuildRawEMLForDraftCreate_NoLocalImages(t *testing.T) {
|
||||
Body: `<p>Hello <b>world</b></p>`,
|
||||
}
|
||||
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit(t *testing.T) {
|
||||
Attach: "./big.txt",
|
||||
}
|
||||
|
||||
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected size limit error when auto-resolved image + attachment exceed 25MB")
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError(t *testing.T) {
|
||||
Inline: `[{"cid":"orphan","file_path":"./unused.png"}]`,
|
||||
}
|
||||
|
||||
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for orphaned --inline CID not referenced in body")
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) {
|
||||
Inline: `[{"cid":"present","file_path":"./present.png"}]`,
|
||||
}
|
||||
|
||||
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing CID reference")
|
||||
}
|
||||
@@ -183,7 +183,7 @@ func TestBuildRawEMLForDraftCreate_WithPriority(t *testing.T) {
|
||||
Body: `<p>Hello</p>`,
|
||||
}
|
||||
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "1", nil, "", "", nil, nil)
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "1", nil, "", "", nil, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -201,7 +201,7 @@ func TestBuildRawEMLForDraftCreate_NoPriority(t *testing.T) {
|
||||
Body: `<p>Hello</p>`,
|
||||
}
|
||||
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -237,7 +237,7 @@ func TestBuildRawEMLForDraftCreate_RequestReceiptAddsHeader(t *testing.T) {
|
||||
}
|
||||
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(),
|
||||
newRuntimeWithFromAndRequestReceipt("sender@example.com", true), input, nil, "", nil, "", "", nil, nil)
|
||||
newRuntimeWithFromAndRequestReceipt("sender@example.com", true), input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -260,7 +260,7 @@ func TestBuildRawEMLForDraftCreate_RequestReceiptOmittedByDefault(t *testing.T)
|
||||
}
|
||||
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(),
|
||||
newRuntimeWithFromAndRequestReceipt("sender@example.com", false), input, nil, "", nil, "", "", nil, nil)
|
||||
newRuntimeWithFromAndRequestReceipt("sender@example.com", false), input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -283,7 +283,7 @@ func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
|
||||
PlainText: true,
|
||||
}
|
||||
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -304,7 +304,7 @@ func TestBuildRawEMLForDraftCreate_WithCalendarEvent(t *testing.T) {
|
||||
Body: "<p>Please join us</p>",
|
||||
}
|
||||
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), rt, input, nil, "", nil, "", "", nil, nil)
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), rt, input, nil, "", nil, "", "", nil, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ var MailDraftEdit = common.Shortcut{
|
||||
for i := range patch.Ops {
|
||||
switch patch.Ops[i].Op {
|
||||
case "insert_signature":
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail)
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail, true, true)
|
||||
if sigErr != nil {
|
||||
return sigErr
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ var MailForward = common.Shortcut{
|
||||
{Name: "subject", Desc: "Optional. Override the auto-generated Fw: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
|
||||
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are merged into the forward draft (template values appended to user flags / forward-derived values; no de-duplication)."},
|
||||
signatureFlag,
|
||||
noSignatureFlag,
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
|
||||
showLintDetailsFlag},
|
||||
@@ -96,7 +97,7 @@ var MailForward = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEventFlags(runtime); err != nil {
|
||||
@@ -127,12 +128,7 @@ var MailForward = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
signatureID := runtime.Str("signature-id")
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
|
||||
if sigErr != nil {
|
||||
return sigErr
|
||||
}
|
||||
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to fetch original message")
|
||||
@@ -156,6 +152,18 @@ var MailForward = common.Shortcut{
|
||||
senderEmail = orig.headTo
|
||||
}
|
||||
|
||||
// Signature ID is resolved here (after senderEmail is finalised) so DefaultReplyID
|
||||
// matches the correct usage. The actual image download in resolveSignature is deferred
|
||||
// to after applyTemplate so the final plainText value (which a template can override
|
||||
// via IsPlainTextMode) is used for the downloadImages decision.
|
||||
signatureID := runtime.Str("signature-id")
|
||||
noSignature := runtime.Bool("no-signature")
|
||||
if noSignature {
|
||||
signatureID = ""
|
||||
} else if signatureID == "" {
|
||||
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, true /*isReply*/)
|
||||
}
|
||||
|
||||
// --template-id merge (§5.5 Q1-Q5).
|
||||
var templateLargeAttachmentIDs []string
|
||||
var templateInlineAttachments []templateInlineRef
|
||||
@@ -198,6 +206,14 @@ var MailForward = common.Shortcut{
|
||||
"bccs_count": countAddresses(bccFlag),
|
||||
})
|
||||
}
|
||||
// Resolve signature after template processing so plainText reflects any IsPlainTextMode
|
||||
// override from the template. This avoids downloading HTML signature images when the
|
||||
// template forces plain-text mode, which could cause CDN 403/5xx or timeout errors.
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
|
||||
runtime.Str("signature-id") != "", !plainText)
|
||||
if sigErr != nil {
|
||||
return sigErr
|
||||
}
|
||||
subjectOverride := strings.TrimSpace(runtime.Str("subject"))
|
||||
|
||||
// Post-merge recipient check for --confirm-send + --template-id:
|
||||
@@ -310,7 +326,7 @@ var MailForward = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
composedTextBody = buildForwardedMessage(&orig, body)
|
||||
composedTextBody = buildForwardedMessage(&orig, injectPlainTextSignature(body, sigResult))
|
||||
bld = bld.TextBody([]byte(composedTextBody))
|
||||
}
|
||||
// Embed template SMALL non-inline attachments regardless of body mode.
|
||||
|
||||
@@ -42,6 +42,7 @@ var MailReply = common.Shortcut{
|
||||
{Name: "subject", Desc: "Optional. Override the auto-generated Re: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
|
||||
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are appended to the reply-derived values (no de-duplication; see warning in Execute output)."},
|
||||
signatureFlag,
|
||||
noSignatureFlag,
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
|
||||
showLintDetailsFlag},
|
||||
@@ -93,7 +94,7 @@ var MailReply = common.Shortcut{
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEventFlags(runtime); err != nil {
|
||||
@@ -129,12 +130,7 @@ var MailReply = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
signatureID := runtime.Str("signature-id")
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
|
||||
if sigErr != nil {
|
||||
return sigErr
|
||||
}
|
||||
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to fetch original message")
|
||||
@@ -156,6 +152,18 @@ var MailReply = common.Shortcut{
|
||||
senderEmail = orig.headTo
|
||||
}
|
||||
|
||||
// Signature ID is resolved here (after senderEmail is finalised) so DefaultReplyID
|
||||
// matches the correct usage. The actual image download in resolveSignature is deferred
|
||||
// to after applyTemplate so the final plainText value (which a template can override
|
||||
// via IsPlainTextMode) is used for the downloadImages decision.
|
||||
signatureID := runtime.Str("signature-id")
|
||||
noSignature := runtime.Bool("no-signature")
|
||||
if noSignature {
|
||||
signatureID = ""
|
||||
} else if signatureID == "" {
|
||||
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, true /*isReply*/)
|
||||
}
|
||||
|
||||
replyTo := orig.replyTo
|
||||
if replyTo == "" {
|
||||
replyTo = orig.headFrom
|
||||
@@ -208,6 +216,14 @@ var MailReply = common.Shortcut{
|
||||
"bccs_count": countAddresses(bccFlag),
|
||||
})
|
||||
}
|
||||
// Resolve signature after template processing so plainText reflects any IsPlainTextMode
|
||||
// override from the template. This avoids downloading HTML signature images when the
|
||||
// template forces plain-text mode, which could cause CDN 403/5xx or timeout errors.
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
|
||||
runtime.Str("signature-id") != "", !plainText)
|
||||
if sigErr != nil {
|
||||
return sigErr
|
||||
}
|
||||
// --subject (explicit override) takes precedence over auto-generated.
|
||||
subjectOverride := strings.TrimSpace(runtime.Str("subject"))
|
||||
|
||||
@@ -311,7 +327,7 @@ var MailReply = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
composedTextBody = bodyStr + quoted
|
||||
composedTextBody = injectPlainTextSignature(bodyStr, sigResult) + quoted
|
||||
bld = bld.TextBody([]byte(composedTextBody))
|
||||
}
|
||||
// Embed template SMALL non-inline attachments regardless of body mode.
|
||||
|
||||
@@ -43,6 +43,7 @@ var MailReplyAll = common.Shortcut{
|
||||
{Name: "subject", Desc: "Optional. Override the auto-generated Re: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
|
||||
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are appended to the reply-derived values (no de-duplication; see warning in Execute output)."},
|
||||
signatureFlag,
|
||||
noSignatureFlag,
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
|
||||
showLintDetailsFlag},
|
||||
@@ -94,7 +95,7 @@ var MailReplyAll = common.Shortcut{
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEventFlags(runtime); err != nil {
|
||||
@@ -131,12 +132,7 @@ var MailReplyAll = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
signatureID := runtime.Str("signature-id")
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
|
||||
if sigErr != nil {
|
||||
return sigErr
|
||||
}
|
||||
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
|
||||
if err != nil {
|
||||
return mailDecorateProblemMessage(err, "failed to fetch original message")
|
||||
@@ -158,6 +154,18 @@ var MailReplyAll = common.Shortcut{
|
||||
senderEmail = orig.headTo
|
||||
}
|
||||
|
||||
// Signature ID is resolved here (after senderEmail is finalised) so DefaultReplyID
|
||||
// matches the correct usage. The actual image download in resolveSignature is deferred
|
||||
// to after applyTemplate so the final plainText value (which a template can override
|
||||
// via IsPlainTextMode) is used for the downloadImages decision.
|
||||
signatureID := runtime.Str("signature-id")
|
||||
noSignature := runtime.Bool("no-signature")
|
||||
if noSignature {
|
||||
signatureID = ""
|
||||
} else if signatureID == "" {
|
||||
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, true /*isReply*/)
|
||||
}
|
||||
|
||||
var removeList []string
|
||||
for _, r := range strings.Split(removeFlag, ",") {
|
||||
if s := strings.TrimSpace(r); s != "" {
|
||||
@@ -218,6 +226,14 @@ var MailReplyAll = common.Shortcut{
|
||||
"bccs_count": countAddresses(bccFlag),
|
||||
})
|
||||
}
|
||||
// Resolve signature after template processing so plainText reflects any IsPlainTextMode
|
||||
// override from the template. This avoids downloading HTML signature images when the
|
||||
// template forces plain-text mode, which could cause CDN 403/5xx or timeout errors.
|
||||
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
|
||||
runtime.Str("signature-id") != "", !plainText)
|
||||
if sigErr != nil {
|
||||
return sigErr
|
||||
}
|
||||
subjectOverride := strings.TrimSpace(runtime.Str("subject"))
|
||||
|
||||
if err := validateRecipientCount(toList, ccList, bccFlag); err != nil {
|
||||
@@ -316,7 +332,7 @@ var MailReplyAll = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
composedTextBody = bodyStr + quoted
|
||||
composedTextBody = injectPlainTextSignature(bodyStr, sigResult) + quoted
|
||||
bld = bld.TextBody([]byte(composedTextBody))
|
||||
}
|
||||
// Embed template SMALL non-inline attachments regardless of body mode.
|
||||
|
||||
@@ -40,6 +40,7 @@ var MailSend = common.Shortcut{
|
||||
{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed."},
|
||||
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's subject/body/to/cc/bcc/attachments are merged with user-supplied flags (user flags win). Requires --as user."},
|
||||
signatureFlag,
|
||||
noSignatureFlag,
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
|
||||
showLintDetailsFlag},
|
||||
@@ -98,7 +99,7 @@ var MailSend = common.Shortcut{
|
||||
if err := validateSendTime(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
// Resolve the body content first (reading --body-file if set) so
|
||||
@@ -145,6 +146,14 @@ var MailSend = common.Shortcut{
|
||||
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
|
||||
// Auto-resolve default signature when neither --no-signature nor --signature-id is set.
|
||||
noSignature := runtime.Bool("no-signature")
|
||||
if noSignature {
|
||||
signatureID = ""
|
||||
} else if signatureID == "" {
|
||||
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, false)
|
||||
}
|
||||
|
||||
// --template-id merge: fetch template and apply it to compose state.
|
||||
var templateLargeAttachmentIDs []string
|
||||
var templateInlineAttachments []templateInlineRef
|
||||
@@ -195,7 +204,8 @@ var MailSend = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail)
|
||||
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
|
||||
runtime.Str("signature-id") != "", !plainText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -230,7 +240,7 @@ var MailSend = common.Shortcut{
|
||||
// `lint_applied[]` / `original_blocked[]` even on the plain-text path.
|
||||
lintApplied, lintBlocked := emptyLintEnvelopeFields()
|
||||
if plainText {
|
||||
composedTextBody = body
|
||||
composedTextBody = injectPlainTextSignature(body, sigResult)
|
||||
bld = bld.TextBody([]byte(composedTextBody))
|
||||
} else if bodyIsHTML(body) || sigResult != nil {
|
||||
// If signature is requested on plain-text body, auto-upgrade to HTML.
|
||||
|
||||
@@ -6,6 +6,7 @@ package signature
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -55,6 +56,33 @@ func List(runtime *common.RuntimeContext, mailboxID string) ([]Signature, error)
|
||||
return resp.Signatures, nil
|
||||
}
|
||||
|
||||
// DefaultSendID returns the default send-mail signature ID for the given
|
||||
// sender email address. Returns "" if no default is configured.
|
||||
// "0" and empty string are treated as "no default" (API convention).
|
||||
func DefaultSendID(usages []SignatureUsage, emailAddr string) string {
|
||||
for _, u := range usages {
|
||||
if strings.EqualFold(u.EmailAddress, emailAddr) {
|
||||
if u.SendMailSignatureID != "" && u.SendMailSignatureID != "0" {
|
||||
return u.SendMailSignatureID
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DefaultReplyID returns the default reply/forward signature ID for the given
|
||||
// sender email address. Returns "" if no default is configured.
|
||||
func DefaultReplyID(usages []SignatureUsage, emailAddr string) string {
|
||||
for _, u := range usages {
|
||||
if strings.EqualFold(u.EmailAddress, emailAddr) {
|
||||
if u.ReplySignatureID != "" && u.ReplySignatureID != "0" {
|
||||
return u.ReplySignatureID
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Get returns a single signature by ID. Returns an error if not found.
|
||||
func Get(runtime *common.RuntimeContext, mailboxID, signatureID string) (*Signature, error) {
|
||||
resp, err := ListAll(runtime, mailboxID)
|
||||
|
||||
92
shortcuts/mail/signature/provider_test.go
Normal file
92
shortcuts/mail/signature/provider_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package signature
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultSendID_Match(t *testing.T) {
|
||||
usages := []SignatureUsage{
|
||||
{EmailAddress: "user@example.com", SendMailSignatureID: "sig-send-1", ReplySignatureID: "sig-reply-1"},
|
||||
}
|
||||
if got := DefaultSendID(usages, "user@example.com"); got != "sig-send-1" {
|
||||
t.Fatalf("expected sig-send-1, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSendID_CaseInsensitive(t *testing.T) {
|
||||
usages := []SignatureUsage{
|
||||
{EmailAddress: "User@Example.COM", SendMailSignatureID: "sig-send-x"},
|
||||
}
|
||||
if got := DefaultSendID(usages, "user@example.com"); got != "sig-send-x" {
|
||||
t.Fatalf("expected case-insensitive match, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSendID_NoMatch(t *testing.T) {
|
||||
usages := []SignatureUsage{
|
||||
{EmailAddress: "other@example.com", SendMailSignatureID: "sig-other"},
|
||||
}
|
||||
if got := DefaultSendID(usages, "user@example.com"); got != "" {
|
||||
t.Fatalf("expected empty string for no match, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSendID_ZeroIDTreatedAsNone(t *testing.T) {
|
||||
usages := []SignatureUsage{
|
||||
{EmailAddress: "user@example.com", SendMailSignatureID: "0"},
|
||||
}
|
||||
if got := DefaultSendID(usages, "user@example.com"); got != "" {
|
||||
t.Fatalf("expected empty string for ID=0, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSendID_NilUsages(t *testing.T) {
|
||||
if got := DefaultSendID(nil, "user@example.com"); got != "" {
|
||||
t.Fatalf("expected empty string for nil usages, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultReplyID_Match(t *testing.T) {
|
||||
usages := []SignatureUsage{
|
||||
{EmailAddress: "user@example.com", SendMailSignatureID: "sig-send-1", ReplySignatureID: "sig-reply-2"},
|
||||
}
|
||||
if got := DefaultReplyID(usages, "user@example.com"); got != "sig-reply-2" {
|
||||
t.Fatalf("expected sig-reply-2, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultReplyID_CaseInsensitive(t *testing.T) {
|
||||
usages := []SignatureUsage{
|
||||
{EmailAddress: "User@Example.COM", ReplySignatureID: "sig-reply-x"},
|
||||
}
|
||||
if got := DefaultReplyID(usages, "user@example.com"); got != "sig-reply-x" {
|
||||
t.Fatalf("expected case-insensitive match, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultReplyID_NoMatch(t *testing.T) {
|
||||
usages := []SignatureUsage{
|
||||
{EmailAddress: "other@example.com", ReplySignatureID: "sig-reply-other"},
|
||||
}
|
||||
if got := DefaultReplyID(usages, "user@example.com"); got != "" {
|
||||
t.Fatalf("expected empty string for no match, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultReplyID_ZeroIDTreatedAsNone(t *testing.T) {
|
||||
usages := []SignatureUsage{
|
||||
{EmailAddress: "user@example.com", ReplySignatureID: "0"},
|
||||
}
|
||||
if got := DefaultReplyID(usages, "user@example.com"); got != "" {
|
||||
t.Fatalf("expected empty string for ID=0, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultReplyID_NilUsages(t *testing.T) {
|
||||
if got := DefaultReplyID(nil, "user@example.com"); got != "" {
|
||||
t.Fatalf("expected empty string for nil usages, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -25,6 +26,54 @@ var signatureFlag = common.Flag{
|
||||
Desc: "Optional. Signature ID to append after body content. Run `mail +signature` to list available signatures.",
|
||||
}
|
||||
|
||||
// noSignatureFlag is shared by all 5 compose shortcuts.
|
||||
var noSignatureFlag = common.Flag{
|
||||
Name: "no-signature",
|
||||
Type: "bool",
|
||||
Desc: "Skip automatic default signature insertion. Mutually exclusive with --signature-id.",
|
||||
}
|
||||
|
||||
// validateNoSignatureConflict returns a structured validation error when
|
||||
// --no-signature and --signature-id are both set; they are mutually exclusive.
|
||||
func validateNoSignatureConflict(noSignature bool, signatureID string) error {
|
||||
if noSignature && signatureID != "" {
|
||||
return mailValidationParamError("--no-signature", "--no-signature and --signature-id are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// autoResolveSignatureID resolves the default signature ID for the given mailbox/sender.
|
||||
// isReply=true uses DefaultReplyID (+reply/+reply-all/+forward);
|
||||
// isReply=false uses DefaultSendID (+send/+draft-create).
|
||||
// Returns "" on API failure (writes stderr warning) or when no default is configured.
|
||||
func autoResolveSignatureID(runtime *common.RuntimeContext, mailboxID, senderEmail string, isReply bool) string {
|
||||
resp, err := signature.ListAll(runtime, mailboxID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"warning: failed to fetch default signature: %v; sending without signature\n", err)
|
||||
return ""
|
||||
}
|
||||
if isReply {
|
||||
return signature.DefaultReplyID(resp.Usages, senderEmail)
|
||||
}
|
||||
return signature.DefaultSendID(resp.Usages, senderEmail)
|
||||
}
|
||||
|
||||
// injectPlainTextSignature appends a plain-text rendering of the signature to a
|
||||
// plain-text body. The HTML signature (sig.RenderedContent) is converted via
|
||||
// draftpkg.PlainTextFromHTML; inline images are dropped (plain text has none).
|
||||
// Returns textBody unchanged when sig is nil.
|
||||
func injectPlainTextSignature(textBody string, sig *signatureResult) string {
|
||||
if sig == nil {
|
||||
return textBody
|
||||
}
|
||||
sigText := strings.TrimRight(draftpkg.PlainTextFromHTML(sig.RenderedContent), "\n")
|
||||
if sigText == "" {
|
||||
return textBody
|
||||
}
|
||||
return textBody + "\n\n" + sigText
|
||||
}
|
||||
|
||||
// signatureResult holds the pre-processed signature data ready for HTML injection.
|
||||
type signatureResult struct {
|
||||
ID string
|
||||
@@ -32,16 +81,32 @@ type signatureResult struct {
|
||||
Images []draftpkg.SignatureImage
|
||||
}
|
||||
|
||||
// resolveSignature fetches, interpolates, and downloads images for a signature.
|
||||
// resolveSignature fetches, interpolates, and optionally downloads images for a signature.
|
||||
// fromEmail is the --from address (may be an alias); used to match the correct
|
||||
// sender identity for template interpolation. Pass "" to use the primary address.
|
||||
func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailboxID, signatureID, fromEmail string) (*signatureResult, error) {
|
||||
//
|
||||
// userExplicit must be true when the caller obtained signatureID from a user-supplied flag
|
||||
// (--signature-id); false when the ID was auto-resolved from default usages. When false,
|
||||
// a "not found" error from the signatures API is treated as graceful degradation (no
|
||||
// signature) rather than a hard failure — this protects against stale default IDs.
|
||||
//
|
||||
// includeImages controls whether inline image attachments are downloaded. Pass false for
|
||||
// plain-text compose paths to avoid unnecessary network I/O (images are discarded in
|
||||
// plain-text mode anyway).
|
||||
func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailboxID, signatureID, fromEmail string, userExplicit, includeImages bool) (*signatureResult, error) {
|
||||
if signatureID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sig, err := signature.Get(runtime, mailboxID, signatureID)
|
||||
if err != nil {
|
||||
if !userExplicit && errs.IsValidation(err) {
|
||||
// Stale auto-resolved default signature ID — degrade gracefully instead of
|
||||
// blocking the entire send/reply/forward operation.
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"warning: default signature %q not found in current list; sending without signature\n", signatureID)
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -50,23 +115,26 @@ func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailb
|
||||
senderName, senderEmail := resolveSenderInfo(runtime, mailboxID, fromEmail)
|
||||
rendered := signature.InterpolateTemplate(sig, lang, senderName, senderEmail)
|
||||
|
||||
// Download signature inline images. The file_key field contains a
|
||||
// direct download URL provided by the mail backend.
|
||||
// Download signature inline images only when the compose path needs them.
|
||||
// Plain-text paths discard images, so skip the download to avoid unnecessary
|
||||
// network I/O (and potential failures from expired pre-signed URLs).
|
||||
var images []draftpkg.SignatureImage
|
||||
for _, img := range sig.Images {
|
||||
if img.DownloadURL == "" || img.CID == "" {
|
||||
continue
|
||||
if includeImages {
|
||||
for _, img := range sig.Images {
|
||||
if img.DownloadURL == "" || img.CID == "" {
|
||||
continue
|
||||
}
|
||||
data, ct, err := downloadSignatureImage(runtime, img.DownloadURL, img.ImageName)
|
||||
if err != nil {
|
||||
return nil, mailDecorateProblemMessage(err, "failed to download signature image %s", img.ImageName)
|
||||
}
|
||||
images = append(images, draftpkg.SignatureImage{
|
||||
CID: img.CID,
|
||||
ContentType: ct,
|
||||
FileName: img.ImageName,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
data, ct, err := downloadSignatureImage(runtime, img.DownloadURL, img.ImageName)
|
||||
if err != nil {
|
||||
return nil, mailDecorateProblemMessage(err, "failed to download signature image %s", img.ImageName)
|
||||
}
|
||||
images = append(images, draftpkg.SignatureImage{
|
||||
CID: img.CID,
|
||||
ContentType: ct,
|
||||
FileName: img.ImageName,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
return &signatureResult{
|
||||
@@ -243,15 +311,3 @@ func signatureCIDs(sig *signatureResult) []string {
|
||||
}
|
||||
return cids
|
||||
}
|
||||
|
||||
// validateSignatureWithPlainText returns an error if both --plain-text and --signature-id are set.
|
||||
func validateSignatureWithPlainText(plainText bool, signatureID string) error {
|
||||
if plainText && signatureID != "" {
|
||||
return mailValidationError("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode").
|
||||
WithParams(
|
||||
mailInvalidParam("--plain-text", "mutually exclusive with --signature-id"),
|
||||
mailInvalidParam("--signature-id", "requires HTML mode"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,204 +4,287 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
||||
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
|
||||
)
|
||||
|
||||
func TestDownloadSignatureImageRejectsInvalidURLs(t *testing.T) {
|
||||
rt := newDownloadRuntime(t, &http.Client{})
|
||||
func TestValidateNoSignatureConflictTypedError(t *testing.T) {
|
||||
err := validateNoSignatureConflict(true, "sig_123")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
// mailValidationParamError returns *errs.ValidationError.
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if valErr.Param != "--no-signature" {
|
||||
t.Errorf("expected Param \"--no-signature\", got %q", valErr.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("error message = %q, want it to contain \"mutually exclusive\"", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNoSignatureConflictNoError(t *testing.T) {
|
||||
if err := validateNoSignatureConflict(false, "sig_123"); err != nil {
|
||||
t.Fatalf("expected no error when noSignature=false, got %v", err)
|
||||
}
|
||||
if err := validateNoSignatureConflict(true, ""); err != nil {
|
||||
t.Fatalf("expected no error when signatureID empty, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectPlainTextSignatureNilSig(t *testing.T) {
|
||||
body := "Hello world"
|
||||
got := injectPlainTextSignature(body, nil)
|
||||
if got != body {
|
||||
t.Fatalf("expected unchanged body %q, got %q", body, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectPlainTextSignatureEmptyHTML(t *testing.T) {
|
||||
sig := &signatureResult{RenderedContent: " <br> "}
|
||||
body := "Hello world"
|
||||
got := injectPlainTextSignature(body, sig)
|
||||
// PlainTextFromHTML on whitespace-only HTML collapses to empty — body unchanged.
|
||||
if got != body {
|
||||
t.Fatalf("expected unchanged body for empty HTML sig, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectPlainTextSignatureAppendsWithBlankLine(t *testing.T) {
|
||||
sig := &signatureResult{RenderedContent: "<div>Best,<br>Bob</div>"}
|
||||
body := "Hello world"
|
||||
got := injectPlainTextSignature(body, sig)
|
||||
if !strings.HasPrefix(got, body+"\n\n") {
|
||||
t.Fatalf("expected body followed by two newlines, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Best,") || !strings.Contains(got, "Bob") {
|
||||
t.Fatalf("expected sig text in result, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectPlainTextSignatureTrimsTrailingNewlines(t *testing.T) {
|
||||
// RenderedContent whose plain-text rendering ends in newlines must be trimmed.
|
||||
sig := &signatureResult{RenderedContent: "<p>Alice</p>"}
|
||||
body := "My message"
|
||||
got := injectPlainTextSignature(body, sig)
|
||||
// Result must not end with a bare newline after the signature text.
|
||||
if strings.HasSuffix(got, "\n") {
|
||||
t.Fatalf("result should not end with newline, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Alice") {
|
||||
t.Fatalf("expected sig text in result, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentTypeFromFilename(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{name: "invalid", url: "https://[::1"},
|
||||
{name: "http", url: "http://example.com/sig.png"},
|
||||
{name: "no host", url: "https:///sig.png"},
|
||||
{"logo.png", "image/png"},
|
||||
{"photo.jpg", "image/jpeg"},
|
||||
{"photo.jpeg", "image/jpeg"},
|
||||
{"anim.gif", "image/gif"},
|
||||
{"icon.webp", "image/webp"},
|
||||
{"draw.svg", "image/svg+xml"},
|
||||
{"bitmap.bmp", "image/bmp"},
|
||||
{"data.bin", "application/octet-stream"},
|
||||
{"noext", "application/octet-stream"},
|
||||
{"UPPER.PNG", "image/png"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, err := downloadSignatureImage(rt, tc.url, "sig.png")
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("expected internal error, got %T (%v)", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
})
|
||||
got := contentTypeFromFilename(tc.name)
|
||||
if got != tc.want {
|
||||
t.Errorf("contentTypeFromFilename(%q) = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadSignatureImageHTTPErrorClassification(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
statusCode int
|
||||
wantType any
|
||||
wantSub errs.Subtype
|
||||
retryable bool
|
||||
}{
|
||||
{
|
||||
name: "server",
|
||||
statusCode: http.StatusInternalServerError,
|
||||
wantType: (*errs.NetworkError)(nil),
|
||||
wantSub: errs.SubtypeNetworkServer,
|
||||
retryable: true,
|
||||
func TestSignatureCIDsNilSig(t *testing.T) {
|
||||
if cids := signatureCIDs(nil); cids != nil {
|
||||
t.Fatalf("expected nil slice for nil sig, got %v", cids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureCIDsFiltersEmpty(t *testing.T) {
|
||||
sig := &signatureResult{
|
||||
Images: []draftpkg.SignatureImage{
|
||||
{CID: "abc123"},
|
||||
{CID: ""},
|
||||
{CID: "<def456>"},
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
statusCode: http.StatusNotFound,
|
||||
wantType: (*errs.APIError)(nil),
|
||||
wantSub: errs.SubtypeNotFound,
|
||||
}
|
||||
cids := signatureCIDs(sig)
|
||||
// normalizeInlineCID strips angle brackets; empty CID is filtered out.
|
||||
if len(cids) != 2 {
|
||||
t.Fatalf("expected 2 CIDs, got %d: %v", len(cids), cids)
|
||||
}
|
||||
for _, c := range cids {
|
||||
if c == "" {
|
||||
t.Errorf("CID must not be empty string; got %v", cids)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectSignatureIntoBodyNilSig(t *testing.T) {
|
||||
html := "<div>body</div>"
|
||||
got := injectSignatureIntoBody(html, nil)
|
||||
if got != html {
|
||||
t.Fatalf("expected unchanged body for nil sig, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectSignatureIntoBodyInjectsSig(t *testing.T) {
|
||||
html := "<div>Hello</div>"
|
||||
sig := &signatureResult{
|
||||
ID: "sig1",
|
||||
RenderedContent: "<div>-- Alice</div>",
|
||||
}
|
||||
got := injectSignatureIntoBody(html, sig)
|
||||
if !strings.Contains(got, "sig1") && !strings.Contains(got, "Alice") {
|
||||
t.Fatalf("expected signature content in result, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddSignatureImagesToBuilderNilSig(t *testing.T) {
|
||||
bld := emlbuilder.New()
|
||||
got := addSignatureImagesToBuilder(bld, nil)
|
||||
// nil sig must return the builder unchanged (no panic, no nil return).
|
||||
_ = got
|
||||
}
|
||||
|
||||
func TestAddSignatureImagesToBuilderWithImages(t *testing.T) {
|
||||
bld := emlbuilder.New()
|
||||
sig := &signatureResult{
|
||||
Images: []draftpkg.SignatureImage{
|
||||
{CID: "img1", ContentType: "image/png", FileName: "logo.png", Data: []byte("fake")},
|
||||
{CID: "", ContentType: "image/jpeg", FileName: "skip.jpg", Data: []byte("fake")}, // empty CID skipped
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "download failed", tc.statusCode)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
rt := newDownloadRuntime(t, srv.Client())
|
||||
}
|
||||
// Should not panic; empty CID entry is silently skipped.
|
||||
got := addSignatureImagesToBuilder(bld, sig)
|
||||
_ = got
|
||||
}
|
||||
|
||||
_, _, err := downloadSignatureImage(rt, srv.URL+"/sig.png", "sig.png")
|
||||
switch tc.wantType.(type) {
|
||||
case *errs.NetworkError:
|
||||
var networkErr *errs.NetworkError
|
||||
if !errors.As(err, &networkErr) {
|
||||
t.Fatalf("expected network error, got %T (%v)", err, err)
|
||||
}
|
||||
case *errs.APIError:
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected API error, got %T (%v)", err, err)
|
||||
}
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if p.Code != tc.statusCode {
|
||||
t.Fatalf("code = %d, want %d", p.Code, tc.statusCode)
|
||||
}
|
||||
if p.Subtype != tc.wantSub {
|
||||
t.Fatalf("subtype = %q, want %q", p.Subtype, tc.wantSub)
|
||||
}
|
||||
if p.Retryable != tc.retryable {
|
||||
t.Fatalf("retryable = %v, want %v", p.Retryable, tc.retryable)
|
||||
}
|
||||
})
|
||||
// newSigTestRuntime creates a RuntimeContext backed by an httpmock.Registry for
|
||||
// tests that exercise signature API code paths (autoResolveSignatureID, resolveSignature).
|
||||
func newSigTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_sigtest"}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+test"}, cfg, f, core.AsUser)
|
||||
return rt, reg
|
||||
}
|
||||
|
||||
// stubSigListResponse registers a signatures list stub for the given mailboxID.
|
||||
func stubSigListResponse(reg *httpmock.Registry, mailboxID string, sigs []map[string]interface{}, usages []map[string]interface{}) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/mail/v1/user_mailboxes/" + mailboxID + "/settings/signatures",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"signatures": sigs,
|
||||
"usages": usages,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestAutoResolveSignatureID_APIFailureReturnsEmpty(t *testing.T) {
|
||||
rt, reg := newSigTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/mail/v1/user_mailboxes/mbx-api-fail/settings/signatures",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{"code": 500, "msg": "internal server error"},
|
||||
})
|
||||
got := autoResolveSignatureID(rt, "mbx-api-fail", "user@example.com", false)
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty string on API failure, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadSignatureImageReadAndSizeErrors(t *testing.T) {
|
||||
readErr := errors.New("socket closed")
|
||||
rt := newDownloadRuntime(t, &http.Client{
|
||||
Transport: signatureRoundTripper(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: signatureErrorBody{err: readErr},
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
func TestAutoResolveSignatureID_NoDefaultConfigured(t *testing.T) {
|
||||
rt, reg := newSigTestRuntime(t)
|
||||
stubSigListResponse(reg, "mbx-no-default", nil, []map[string]interface{}{
|
||||
{"email_address": "other@example.com", "send_mail_signature_id": "sig-other"},
|
||||
})
|
||||
|
||||
_, _, err := downloadSignatureImage(rt, "https://example.com/sig.png", "sig.png")
|
||||
var networkErr *errs.NetworkError
|
||||
if !errors.As(err, &networkErr) {
|
||||
t.Fatalf("expected network error, got %T (%v)", err, err)
|
||||
}
|
||||
if !errors.Is(err, readErr) {
|
||||
t.Fatalf("read cause not preserved: %v", err)
|
||||
}
|
||||
|
||||
rt = newDownloadRuntime(t, &http.Client{
|
||||
Transport: signatureRoundTripper(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: &bodyFileTestFile{remaining: 10*1024*1024 + 1},
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
_, _, err = downloadSignatureImage(rt, "https://example.com/huge.png", "huge.png")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T (%v)", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeFailedPrecondition)
|
||||
got := autoResolveSignatureID(rt, "mbx-no-default", "user@example.com", false)
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty string when no default configured for sender, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadSignatureImageSuccessUsesFilenameContentType(t *testing.T) {
|
||||
rt := newDownloadRuntime(t, &http.Client{
|
||||
Transport: signatureRoundTripper(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("gif-data")),
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
func TestAutoResolveSignatureID_ReturnsSendID(t *testing.T) {
|
||||
rt, reg := newSigTestRuntime(t)
|
||||
stubSigListResponse(reg, "mbx-send-id", nil, []map[string]interface{}{
|
||||
{"email_address": "user@example.com", "send_mail_signature_id": "sig-send-42", "reply_signature_id": "sig-reply-42"},
|
||||
})
|
||||
got := autoResolveSignatureID(rt, "mbx-send-id", "user@example.com", false)
|
||||
if got != "sig-send-42" {
|
||||
t.Fatalf("expected send default sig ID %q, got %q", "sig-send-42", got)
|
||||
}
|
||||
}
|
||||
|
||||
data, contentType, err := downloadSignatureImage(rt, "https://example.com/sig.gif", "sig.gif")
|
||||
func TestAutoResolveSignatureID_ReturnsReplyID(t *testing.T) {
|
||||
rt, reg := newSigTestRuntime(t)
|
||||
stubSigListResponse(reg, "mbx-reply-id", nil, []map[string]interface{}{
|
||||
{"email_address": "user@example.com", "send_mail_signature_id": "sig-send-42", "reply_signature_id": "sig-reply-42"},
|
||||
})
|
||||
got := autoResolveSignatureID(rt, "mbx-reply-id", "user@example.com", true)
|
||||
if got != "sig-reply-42" {
|
||||
t.Fatalf("expected reply default sig ID %q, got %q", "sig-reply-42", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSignature_EmptyIDReturnsNil(t *testing.T) {
|
||||
rt, _ := newSigTestRuntime(t)
|
||||
result, err := resolveSignature(context.Background(), rt, "mbx-empty", "", "user@example.com", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadSignatureImage failed: %v", err)
|
||||
t.Fatalf("unexpected error for empty signatureID: %v", err)
|
||||
}
|
||||
if string(data) != "gif-data" {
|
||||
t.Fatalf("data = %q", string(data))
|
||||
}
|
||||
if contentType != "image/gif" {
|
||||
t.Fatalf("content type = %q, want image/gif", contentType)
|
||||
if result != nil {
|
||||
t.Fatalf("expected nil result for empty signatureID, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSignatureWithPlainTextTypedError(t *testing.T) {
|
||||
err := validateSignatureWithPlainText(true, "sig_123")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T (%v)", err, err)
|
||||
func TestResolveSignature_StaleIDAutoDegradesGracefully(t *testing.T) {
|
||||
rt, reg := newSigTestRuntime(t)
|
||||
// API returns an empty list — stale ID not found → ValidationError in Get.
|
||||
stubSigListResponse(reg, "mbx-stale-auto", nil, nil)
|
||||
result, err := resolveSignature(context.Background(), rt, "mbx-stale-auto", "sig-stale", "user@example.com", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("expected graceful degradation (nil error), got: %v", err)
|
||||
}
|
||||
if len(validationErr.Params) != 2 {
|
||||
t.Fatalf("params = %#v, want two conflicting params", validationErr.Params)
|
||||
}
|
||||
if validationErr.Params[0].Name != "--plain-text" || validationErr.Params[1].Name != "--signature-id" {
|
||||
t.Fatalf("unexpected params: %#v", validationErr.Params)
|
||||
if result != nil {
|
||||
t.Fatalf("expected nil result for stale auto-resolved ID, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
type signatureRoundTripper func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (rt signatureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return rt(req)
|
||||
}
|
||||
|
||||
type signatureErrorBody struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (b signatureErrorBody) Read([]byte) (int, error) {
|
||||
return 0, b.err
|
||||
}
|
||||
|
||||
func (b signatureErrorBody) Close() error {
|
||||
return nil
|
||||
func TestResolveSignature_StaleIDUserExplicitFails(t *testing.T) {
|
||||
rt, reg := newSigTestRuntime(t)
|
||||
stubSigListResponse(reg, "mbx-stale-explicit", nil, nil)
|
||||
_, err := resolveSignature(context.Background(), rt, "mbx-stale-explicit", "sig-stale", "user@example.com", true, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for stale ID with userExplicit=true, got nil")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ lark-cli docs +create --api-version v2 --parent-token fldcnXXXX --content '<titl
|
||||
# 创建到个人知识库(XML)
|
||||
lark-cli docs +create --api-version v2 --parent-position my_library --content '<title>标题</title><p>内容</p>'
|
||||
|
||||
# 仅当用户明确要求时才使用 Markdown
|
||||
# 仅当用户明确要求时才使用 Markdown;文档标题必须是开头唯一的一级标题,正文从二级标题开始
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项目计划\n\n## 目标\n\n- 目标 1\n- 目标 2'
|
||||
```
|
||||
|
||||
@@ -72,7 +72,7 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项
|
||||
|
||||
## 最佳实践
|
||||
|
||||
- 文档标题从内容中自动提取(XML `<title>` 或 Markdown `#`),不要在内容开头重复写标题
|
||||
- 文档标题从内容中自动提取:XML 使用 `<title>`;Markdown 使用文档开头唯一的一级标题(`# 标题`),正文从 `##` 开始。不要在内容开头重复写标题,也不要在 Markdown 正文中使用多个一级标题。
|
||||
- **创建较长的文档时只建骨架**:`--content` 仅传标题 + 各级 heading + 简短占位摘要;正文留给后续 `block_insert_after --block-id <章节标题 block_id>` 分段追加。一次性塞超长 `--content` 既容易触发参数限制,调试也更难。
|
||||
- **视觉丰富度**:必须遵循 [`lark-doc-style.md`](style/lark-doc-style.md) 中的样式指南,主动使用结构化 block 丰富文档
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
`docs +fetch --api-version v2` / `docs +create --api-version v2` / `docs +update --api-version v2` 使用 `--doc-format markdown` 时适用。
|
||||
|
||||
## 创建文档标题
|
||||
|
||||
使用 `docs +create --doc-format markdown` 创建文档时,文档标题必须写成内容开头唯一的一级标题:`# 标题`。正文标题从 `##` 开始,不要使用多个一级标题;否则标题可能无法被提取并显示为 `Untitled`。
|
||||
|
||||
## 转义规则
|
||||
|
||||
> **⚠️ 当文本中包含以下字符且不想触发 Markdown 语法时**,需用 `\` 前缀转义。转义分为**无条件转义**(行内任意位置生效)和**位置敏感转义**(仅特定位置才需要)两类。
|
||||
@@ -73,4 +77,4 @@ Markdown 格式支持通过 URL 插入网络图片,图片将自动从 HTTP 下
|
||||
|
||||
## 参考
|
||||
|
||||
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
||||
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
||||
|
||||
@@ -113,6 +113,8 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_replace
|
||||
--content '<p>替换后的段落内容</p>'
|
||||
```
|
||||
|
||||
> `block_replace` 由服务端执行整块替换,目标 block 的 ID 不保证在替换后继续可用。后续如果还要在替换后的块附近继续 `block_insert_after`、`range` 或其他 block 级操作,先重新 `docs +fetch --detail with-ids` 获取最新 block ID,不要复用旧 ID。
|
||||
|
||||
### block_delete — 删除指定 block
|
||||
|
||||
```bash
|
||||
@@ -234,6 +236,7 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
|
||||
- **保护不可重建的内容**:图片、画板、电子表格等以 token 形式存储,替换时避开这些 block
|
||||
- **str_replace 的 replacement 支持富文本**:可以用行内标签 `<b>`、`<a>`、`<cite>`、`<latex>` 等替换普通文本为富文本
|
||||
- **同一 block 只能被 replace 一次**:多次修改同一 block 请合并为一次 block_replace
|
||||
- **block_replace 后重新获取 ID**:`block_replace` 成功后旧 block ID 不保证继续可用;继续做相邻块操作前,重新 `docs +fetch --detail with-ids`
|
||||
- **block_delete 支持批量**:用逗号分隔多个 block_id 一次删除
|
||||
- **复杂结构重组**:将多个段落转换为 grid / table 等复杂布局时,分步操作比 overwrite 更安全:
|
||||
1. 用 `block_insert_after` 在目标位置插入新的富文本结构
|
||||
|
||||
@@ -10,6 +10,10 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
| `<title>` | 文档标题(每篇唯一)| `align` |
|
||||
| `<checkbox>` | 待办项| `done="true"\|"false"` |
|
||||
|
||||
## 创建文档标题
|
||||
|
||||
使用 `docs +create` 创建 XML 文档时,文档标题必须写成 `<title>标题</title>`,且每篇文档只写一个 `<title>`。
|
||||
|
||||
## 容器标签
|
||||
|标签|说明|关键属性|
|
||||
|-|-|-|
|
||||
@@ -77,6 +81,10 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
</ul>
|
||||
```
|
||||
|
||||
## 代码块
|
||||
- 代码块必须写成 `<pre lang="xxx" caption="可选说明"><code>代码内容</code></pre>`。
|
||||
- 不要将代码文本直接放在 `<pre>` 下;应放在内层 `<code>` 中。
|
||||
|
||||
|
||||
## 用户名写入规则
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
> 这是 Drive 导入场景,不是 `lark-base` 的建表 / 写记录场景。
|
||||
> 只有导入完成并拿到新文档的 `token` / `url` 后,后续字段、记录、视图等表内操作才切换到 `lark-cli base +...`。
|
||||
|
||||
## 导入后标题确认
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 当用户**未传 `--name`** 时,文档标题默认取源文件名(去掉扩展名)。在执行导入前,先友好提示用户:「当前未指定文档标题,默认将使用"xxx"作为标题。如果文件内容中也包含相同标题,导入后可能造成视觉重复。是否需要重命名?」让用户确认后再继续。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
|
||||
@@ -52,10 +52,11 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te
|
||||
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用,如通过别名或 send_as 地址发信。可通过 `accessible_mailboxes` 查询可用邮箱 |
|
||||
| `--cc <emails>` | 否 | 完整抄送列表,多个用逗号分隔 |
|
||||
| `--bcc <emails>` | 否 | 完整密送列表,多个用逗号分隔。与 `--event-*` 不兼容(见 `+send` 日程邀请约束) |
|
||||
| `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用 |
|
||||
| `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用。纯文本模式下也会自动追加纯文本签名(HTML 签名经 `PlainTextFromHTML` 转换,内联图片丢弃) |
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径。当附件导致 EML 总大小超过 25 MB 时,超出部分自动上传为超大附件(HTML 邮件插入下载卡片,纯文本邮件追加下载链接),单个文件上限 3 GB |
|
||||
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。与 `--no-signature` 互斥 |
|
||||
| `--no-signature` | 否 | 跳过默认签名自动追加。与 `--signature-id` 互斥,同时使用时返回参数校验错误(退出码 2) |
|
||||
| `--priority <level>` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 |
|
||||
| `--request-receipt` | 否 | 请求已读回执(RFC 3798 Message Disposition Notification)。在草稿 EML 里写 `Disposition-Notification-To: <sender>` 头,发送时生效。收件人的邮件客户端可能弹出提示、自动发送或忽略——送达不保证 |
|
||||
| `--event-summary <text>` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请。需同时设置 `--event-start` 和 `--event-end` |
|
||||
|
||||
@@ -68,10 +68,11 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run
|
||||
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用。可通过 `accessible_mailboxes` 查询可用邮箱 |
|
||||
| `--cc <emails>` | 否 | 抄送邮箱,多个用逗号分隔 |
|
||||
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔。与 `--event-*` 不兼容(见 `+send` 日程邀请约束) |
|
||||
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 |
|
||||
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用。纯文本模式下也会自动追加纯文本签名(HTML 签名经 `PlainTextFromHTML` 转换,内联图片丢弃) |
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔,追加在原邮件附件之后。相对路径。当附件导致 EML 总大小超过 25 MB 时,超出部分自动上传为超大附件(HTML 邮件插入下载卡片,纯文本邮件追加下载链接),单个文件上限 3 GB |
|
||||
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到转发正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到转发正文与引用块之间。运行 `mail +signature` 查看可用签名。与 `--no-signature` 互斥 |
|
||||
| `--no-signature` | 否 | 跳过默认签名自动追加。与 `--signature-id` 互斥,同时使用时返回参数校验错误(退出码 2) |
|
||||
| `--priority <level>` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 |
|
||||
| `--event-summary <text>` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请。需同时设置 `--event-start` 和 `--event-end` |
|
||||
| `--event-start <time>` | 条件必填 | 日程开始时间(ISO 8601) |
|
||||
|
||||
@@ -72,10 +72,11 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run
|
||||
| `--cc <emails>` | 否 | 额外抄送,多个用逗号分隔 |
|
||||
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔。与 `--event-*` 不兼容(见 `+send` 日程邀请约束) |
|
||||
| `--remove <emails>` | 否 | 从自动聚合结果中排除的邮箱,多个用逗号分隔 |
|
||||
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 |
|
||||
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用。纯文本模式下也会自动追加纯文本签名(HTML 签名经 `PlainTextFromHTML` 转换,内联图片丢弃) |
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径。当附件导致 EML 总大小超过 25 MB 时,超出部分自动上传为超大附件(HTML 邮件插入下载卡片,纯文本邮件追加下载链接),单个文件上限 3 GB |
|
||||
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。与 `--no-signature` 互斥 |
|
||||
| `--no-signature` | 否 | 跳过默认签名自动追加。与 `--signature-id` 互斥,同时使用时返回参数校验错误(退出码 2) |
|
||||
| `--priority <level>` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 |
|
||||
| `--event-summary <text>` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请。需同时设置 `--event-start` 和 `--event-end` |
|
||||
| `--event-start <time>` | 条件必填 | 日程开始时间(ISO 8601) |
|
||||
|
||||
@@ -75,10 +75,11 @@ lark-cli mail +reply --message-id <邮件ID> --body '<p>测试</p>' --dry-run
|
||||
| `--to <emails>` | 否 | 额外收件人,多个用逗号分隔(追加到原发件人) |
|
||||
| `--cc <emails>` | 否 | 抄送邮箱,多个用逗号分隔 |
|
||||
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔。与 `--event-*` 不兼容(见 `+send` 日程邀请约束) |
|
||||
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 |
|
||||
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用。纯文本模式下也会自动追加纯文本签名(HTML 签名经 `PlainTextFromHTML` 转换,内联图片丢弃) |
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径。当附件导致 EML 总大小超过 25 MB 时,超出部分自动上传为超大附件(HTML 邮件插入下载卡片,纯文本邮件追加下载链接),单个文件上限 3 GB |
|
||||
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。与 `--no-signature` 互斥 |
|
||||
| `--no-signature` | 否 | 跳过默认签名自动追加。与 `--signature-id` 互斥,同时使用时返回参数校验错误(退出码 2) |
|
||||
| `--priority <level>` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 |
|
||||
| `--event-summary <text>` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请。需同时设置 `--event-start` 和 `--event-end` |
|
||||
| `--event-start <time>` | 条件必填 | 日程开始时间(ISO 8601) |
|
||||
|
||||
@@ -75,10 +75,11 @@ lark-cli mail +send --to alice@example.com --subject '测试' --body '<p>test</p
|
||||
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用。可通过 `accessible_mailboxes` 查询可用邮箱 |
|
||||
| `--cc <emails>` | 否 | 抄送邮箱,多个用逗号分隔 |
|
||||
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔 |
|
||||
| `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用 |
|
||||
| `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用。纯文本模式下也会自动追加纯文本签名(HTML 签名经 `PlainTextFromHTML` 转换,内联图片丢弃) |
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径。当附件导致 EML 总大小超过 25 MB 时,超出部分自动上传为超大附件(HTML 邮件插入下载卡片,纯文本邮件追加下载链接),单个文件上限 3 GB |
|
||||
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 |
|
||||
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。与 `--no-signature` 互斥 |
|
||||
| `--no-signature` | 否 | 跳过默认签名自动追加。与 `--signature-id` 互斥,同时使用时返回参数校验错误(退出码 2) |
|
||||
| `--priority <level>` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 |
|
||||
| `--event-summary <text>` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请(text/calendar)。需同时设置 `--event-start` 和 `--event-end` |
|
||||
| `--event-start <time>` | 条件必填 | 日程开始时间(ISO 8601,如 `2026-04-20T14:00+08:00`) |
|
||||
|
||||
Reference in New Issue
Block a user