From d5354e5132d9d7269c60209709ef0ce33d85bc44 Mon Sep 17 00:00:00 2001 From: xuzhuocong Date: Fri, 5 Jun 2026 04:35:00 +0000 Subject: [PATCH 1/2] feat(mail): auto-append default signature in compose shortcuts - Add --no-signature flag to 5 compose shortcuts (+send, +draft-create, +reply, +reply-all, +forward) - Add DefaultSendID/DefaultReplyID helpers in shortcuts/mail/signature/provider.go - Auto-fetch and append default signature when no explicit --signature-id is given - Gracefully degrade (log warning, continue sending) if signature fetch fails - Update skills reference docs to document the new --no-signature flag --- shortcuts/mail/mail_draft_create.go | 136 +++++++++++++----- shortcuts/mail/mail_forward.go | 84 ++++++++--- shortcuts/mail/mail_reply.go | 81 +++++++++-- shortcuts/mail/mail_reply_all.go | 77 ++++++++-- shortcuts/mail/mail_send.go | 85 +++++++++-- shortcuts/mail/signature/provider.go | 49 ++++++- shortcuts/mail/signature_compose.go | 49 ++++--- .../references/lark-mail-draft-create.md | 6 +- .../lark-mail/references/lark-mail-forward.md | 6 +- .../references/lark-mail-reply-all.md | 6 +- .../lark-mail/references/lark-mail-reply.md | 6 +- skills/lark-mail/references/lark-mail-send.md | 6 +- 12 files changed, 463 insertions(+), 128 deletions(-) diff --git a/shortcuts/mail/mail_draft_create.go b/shortcuts/mail/mail_draft_create.go index 990630996..4b27a6ef3 100644 --- a/shortcuts/mail/mail_draft_create.go +++ b/shortcuts/mail/mail_draft_create.go @@ -9,10 +9,11 @@ import ( "io" "strings" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" "github.com/larksuite/cli/shortcuts/mail/emlbuilder" + "github.com/larksuite/cli/shortcuts/mail/lint" + "github.com/larksuite/cli/shortcuts/mail/signature" ) // draftCreateInput bundles all +draft-create user flags into a single @@ -44,7 +45,8 @@ var MailDraftCreate = common.Shortcut{ Flags: []common.Flag{ {Name: "to", Desc: "Optional. Full To recipient list. Separate multiple addresses with commas. Display-name format is supported. When omitted, the draft is created without recipients (they can be added later via +draft-edit)."}, {Name: "subject", Desc: "Final draft subject. Pass the full subject you want to appear in the draft. Required unless --template-id supplies a non-empty subject."}, - {Name: "body", Desc: "Full email body. Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --plain-text to force plain-text mode. Required unless --template-id supplies a non-empty body."}, + {Name: "body", Desc: "Full email body. Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --plain-text to force plain-text mode. Mutually exclusive with --body-file. Required unless --template-id supplies a non-empty body."}, + bodyFileFlag, {Name: "from", Desc: "Optional. Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. If omitted, the mailbox's primary address is used."}, {Name: "mailbox", Desc: "Optional. Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."}, {Name: "cc", Desc: "Optional. Full Cc recipient list. Separate multiple addresses with commas. Display-name format is supported."}, @@ -55,8 +57,10 @@ 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, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { mailboxID := resolveComposeMailboxID(runtime) @@ -82,11 +86,13 @@ var MailDraftCreate = common.Shortcut{ return err } hasTemplate := runtime.Str("template-id") != "" - if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" { - return output.ErrValidation("--subject is required; pass the final email subject (or use --template-id)") + bodyFlag := runtime.Str("body") + bodyFile := strings.TrimSpace(runtime.Str("body-file")) + if err := validateBodyFileMutex(bodyFlag, bodyFile, runtime.ValidatePath); err != nil { + return err } - if !hasTemplate && strings.TrimSpace(runtime.Str("body")) == "" { - return output.ErrValidation("--body is required; pass the full email body (or use --template-id)") + 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 { return err @@ -94,7 +100,16 @@ var MailDraftCreate = common.Shortcut{ if err := validateEventFlags(runtime); err != nil { return err } - if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil { + // Resolve the body (reading --body-file if set) so the inline / + // HTML check sees the real body, not an empty placeholder. + body, bErr := resolveBodyFromFlags(runtime) + if bErr != nil { + return bErr + } + if err := validateRequiredResolvedBody(body, hasTemplate, "--body or --body-file is required; pass the full email body (or use --template-id)"); err != nil { + return err + } + if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), body); err != nil { return err } return validatePriorityFlag(runtime) @@ -105,10 +120,14 @@ var MailDraftCreate = common.Shortcut{ return err } mailboxID := resolveComposeMailboxID(runtime) + body, bErr := resolveBodyFromFlags(runtime) + if bErr != nil { + return bErr + } input := draftCreateInput{ To: runtime.Str("to"), Subject: runtime.Str("subject"), - Body: runtime.Str("body"), + Body: body, From: runtime.Str("from"), CC: runtime.Str("cc"), BCC: runtime.Str("bcc"), @@ -158,28 +177,53 @@ var MailDraftCreate = common.Shortcut{ }) } if strings.TrimSpace(input.Subject) == "" { - return output.ErrValidation("effective subject is empty after applying template; pass --subject explicitly") + return mailValidationParamError("--subject", "effective subject is empty after applying template; pass --subject explicitly") } if strings.TrimSpace(input.Body) == "" { - return output.ErrValidation("effective body is empty after applying template; pass --body explicitly") + return mailValidationParamError("--body", "effective body is empty after applying template; pass --body explicitly") + } + signatureID := runtime.Str("signature-id") + senderEmail := runtime.Str("from") + noSignature := runtime.Bool("no-signature") + if noSignature { + if signatureID != "" { + fmt.Fprintf(runtime.IO().ErrOut, + "warning: --signature-id ignored because --no-signature is set\n") + } + signatureID = "" + } else if signatureID == "" && !input.PlainText { + if resp, lErr := signature.ListAll(runtime, mailboxID); lErr == nil { + signatureID = signature.DefaultSendID(resp.Usages, senderEmail) + } else { + fmt.Fprintf(runtime.IO().ErrOut, + "warning: failed to fetch default signature: %v\n", lErr) + } } - sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from")) + sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail) if err != nil { return err } - rawEML, err := buildRawEMLForDraftCreate(ctx, runtime, input, sigResult, priority, + rawEML, lintApplied, lintBlocked, err := buildRawEMLForDraftCreate(ctx, runtime, input, sigResult, priority, templateLargeAttachmentIDs, mailboxID, templateID, templateInlineAttachments, templateSmallAttachments) if err != nil { return err } draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML) if err != nil { - return fmt.Errorf("create draft failed: %w", err) + return mailDecorateProblemMessage(err, "create draft failed") } out := map[string]interface{}{"draft_id": draftResult.DraftID} if draftResult.Reference != "" { out["reference"] = draftResult.Reference } + // Writing-path lint envelope: default has no lint fields; full Finding + // arrays (`lint_applied[]` / `original_blocked[]`) only when the + // caller asked for them via --show-lint-details. + applyLintToEnvelope(out, lintApplied, lintBlocked, runtime.Bool("show-lint-details")) + addComposeHint(out) + // `draft_edit_hint` is attached ONLY here (+draft-create); the other 5 + // compose shortcuts do not — see addDraftEditHint for the rationale. + addDraftEditHint(out) runtime.OutFormat(out, nil, func(w io.Writer) { fmt.Fprintln(w, "Draft created.") // Intentionally keep +draft-create output minimal: unlike reply/forward/send @@ -202,6 +246,10 @@ var MailDraftCreate = common.Shortcut{ // senderEmail returns an error early. The returned string is ready to POST // to the drafts endpoint. ctx is plumbed through for large-attachment // processing. +// +// Returns the rawEML, the writing-path lint findings (lint_applied / +// original_blocked — never nil; the arrays must always be present), and +// any error encountered. func buildRawEMLForDraftCreate( ctx context.Context, runtime *common.RuntimeContext, @@ -212,14 +260,19 @@ func buildRawEMLForDraftCreate( mailboxID, templateID string, templateInlineAttachments []templateInlineRef, templateSmallAttachments []templateAttachmentRef, -) (string, error) { +) (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) if senderEmail == "" { - return "", fmt.Errorf("unable to determine sender email; please specify --from explicitly") + return "", lintApplied, lintBlocked, mailValidationParamError("--from", "unable to determine sender email; please specify --from explicitly") } if err := validateRecipientCount(input.To, input.CC, input.BCC); err != nil { - return "", err + return "", lintApplied, lintBlocked, err } bld := emlbuilder.New().WithFileIO(runtime.FileIO()). @@ -237,7 +290,7 @@ func buildRawEMLForDraftCreate( // compose shortcuts; if it ever trips in this path, the above check // regressed. if err := requireSenderForRequestReceipt(runtime, senderEmail); err != nil { - return "", err + return "", lintApplied, lintBlocked, err } if runtime.Bool("request-receipt") { bld = bld.DispositionNotificationTo("", senderEmail) @@ -248,9 +301,9 @@ func buildRawEMLForDraftCreate( if input.BCC != "" { bld = bld.BCCAddrs(parseNetAddrs(input.BCC)) } - inlineSpecs, err := parseInlineSpecs(input.Inline) - if err != nil { - return "", output.ErrValidation("%v", err) + inlineSpecs, parseErr := parseInlineSpecs(input.Inline) + if parseErr != nil { + return "", lintApplied, lintBlocked, parseErr } var autoResolvedPaths []string var composedHTMLBody string @@ -265,9 +318,17 @@ func buildRawEMLForDraftCreate( } resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody) if resolveErr != nil { - return "", resolveErr + return "", lintApplied, lintBlocked, mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr) } resolved = injectSignatureIntoBody(resolved, sigResult) + // Writing-path lint: AutoFix=true / Strict=false — the writing-path + // safety contract has no `--no-lint` opt-out. Runs AFTER + // applyTemplate (in caller) + ResolveLocalImagePaths + + // injectSignatureIntoBody so the lint sees the final HTML the + // recipient renderer will see. + cleaned, rep := runWritePathLint(resolved) + resolved = cleaned + lintApplied, lintBlocked = rep.Applied, rep.Blocked composedHTMLBody = resolved bld = bld.HTMLBody([]byte(composedHTMLBody)) bld = addSignatureImagesToBuilder(bld, sigResult) @@ -283,13 +344,14 @@ func buildRawEMLForDraftCreate( } allCIDs = append(allCIDs, signatureCIDs(sigResult)...) var tplInlineCIDs []string - bld, tplInlineCIDs, err = embedTemplateInlineAttachments(ctx, runtime, bld, resolved, mailboxID, templateID, templateInlineAttachments) - if err != nil { - return "", err + var embedErr error + bld, tplInlineCIDs, embedErr = embedTemplateInlineAttachments(ctx, runtime, bld, resolved, mailboxID, templateID, templateInlineAttachments) + if embedErr != nil { + return "", lintApplied, lintBlocked, embedErr } allCIDs = append(allCIDs, tplInlineCIDs...) - if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil { - return "", err + if cidErr := validateInlineCIDs(resolved, allCIDs, nil); cidErr != nil { + return "", lintApplied, lintBlocked, cidErr } } else { composedTextBody = input.Body @@ -299,9 +361,10 @@ func buildRawEMLForDraftCreate( // when the template contributes none; runs in both HTML and plain-text // branches because regular attachments are independent of body mode. var templateSmallBytes int64 - bld, templateSmallBytes, err = embedTemplateSmallAttachments(ctx, runtime, bld, mailboxID, templateID, templateSmallAttachments) - if err != nil { - return "", err + var smallErr error + bld, templateSmallBytes, smallErr = embedTemplateSmallAttachments(ctx, runtime, bld, mailboxID, templateID, templateSmallAttachments) + if smallErr != nil { + return "", lintApplied, lintBlocked, smallErr } bld = applyPriority(bld, priority) if calData := buildCalendarBody(runtime, senderEmail, input.To, input.CC); calData != nil { @@ -310,16 +373,17 @@ func buildRawEMLForDraftCreate( allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...) composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody)) emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, 0) + templateSmallBytes - bld, err = processLargeAttachments(ctx, runtime, bld, composedHTMLBody, composedTextBody, splitByComma(input.Attach), emlBase, 0) - if err != nil { - return "", err + var largeErr error + bld, largeErr = processLargeAttachments(ctx, runtime, bld, composedHTMLBody, composedTextBody, splitByComma(input.Attach), emlBase, 0) + if largeErr != nil { + return "", lintApplied, lintBlocked, largeErr } if hdr, hdrErr := encodeTemplateLargeAttachmentHeader(templateLargeAttachmentIDs); hdrErr == nil && hdr != "" { bld = bld.Header(draftpkg.LargeAttachmentIDsHeader, hdr) } - rawEML, err := bld.BuildBase64URL() - if err != nil { - return "", output.ErrValidation("build EML failed: %v", err) + rawEML, buildErr := bld.BuildBase64URL() + if buildErr != nil { + return "", lintApplied, lintBlocked, mailValidationError("build EML failed: %v", buildErr).WithCause(buildErr) } - return rawEML, nil + return rawEML, lintApplied, lintBlocked, nil } diff --git a/shortcuts/mail/mail_forward.go b/shortcuts/mail/mail_forward.go index 466db2e12..1a6e75c9f 100644 --- a/shortcuts/mail/mail_forward.go +++ b/shortcuts/mail/mail_forward.go @@ -10,10 +10,11 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" "github.com/larksuite/cli/shortcuts/mail/emlbuilder" + "github.com/larksuite/cli/shortcuts/mail/signature" ) // MailForward is the `+forward` shortcut: forward an existing message to @@ -26,10 +27,12 @@ var MailForward = common.Shortcut{ Risk: "write", Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, AuthTypes: []string{"user"}, + HasFormat: true, Flags: []common.Flag{ {Name: "message-id", Desc: "Required. Message ID to forward", Required: true}, {Name: "to", Desc: "Recipient email address(es), comma-separated"}, - {Name: "body", Desc: "Body prepended before the forwarded message. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the forward body and the original message. Use --plain-text to force plain-text mode."}, + {Name: "body", Desc: "Body prepended before the forwarded message. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the forward body and the original message. Use --plain-text to force plain-text mode. Mutually exclusive with --body-file."}, + bodyFileFlag, {Name: "from", Desc: "Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. Defaults to the mailbox's primary address."}, {Name: "mailbox", Desc: "Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."}, {Name: "cc", Desc: "CC email address(es), comma-separated"}, @@ -43,8 +46,10 @@ 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}, + eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag, + showLintDetailsFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { messageId := runtime.Str("message-id") to := runtime.Str("to") @@ -72,6 +77,11 @@ var MailForward = common.Shortcut{ if err := validateTemplateID(runtime.Str("template-id")); err != nil { return err } + bodyFlag := runtime.Str("body") + bodyFile := strings.TrimSpace(runtime.Str("body-file")) + if err := validateBodyFileMutex(bodyFlag, bodyFile, runtime.ValidatePath); err != nil { + return err + } if err := validateConfirmSendScope(runtime); err != nil { return err } @@ -102,7 +112,10 @@ var MailForward = common.Shortcut{ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { messageId := runtime.Str("message-id") to := runtime.Str("to") - body := runtime.Str("body") + body, bErr := resolveBodyFromFlags(runtime) + if bErr != nil { + return bErr + } ccFlag := runtime.Str("cc") bccFlag := runtime.Str("bcc") plainText := runtime.Bool("plain-text") @@ -118,16 +131,31 @@ var MailForward = common.Shortcut{ signatureID := runtime.Str("signature-id") mailboxID := resolveComposeMailboxID(runtime) + noSignature := runtime.Bool("no-signature") + if noSignature { + if signatureID != "" { + fmt.Fprintf(runtime.IO().ErrOut, + "warning: --signature-id ignored because --no-signature is set\n") + } + signatureID = "" + } else if signatureID == "" && !plainText { + if resp, lErr := signature.ListAll(runtime, mailboxID); lErr == nil { + signatureID = signature.DefaultReplyID(resp.Usages, runtime.Str("from")) + } else { + fmt.Fprintf(runtime.IO().ErrOut, + "warning: failed to fetch default signature: %v\n", lErr) + } + } 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 fmt.Errorf("failed to fetch original message: %w", err) + return mailDecorateProblemMessage(err, "failed to fetch original message") } if err := validateForwardAttachmentURLs(sourceMsg); err != nil { - return fmt.Errorf("forward blocked: %w", err) + return mailDecorateProblemMessage(err, "forward blocked") } orig := sourceMsg.Original @@ -232,7 +260,7 @@ var MailForward = common.Shortcut{ } useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil) if strings.TrimSpace(inlineFlag) != "" && !useHTML { - return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML") + return mailValidationParamError("--inline", "--inline requires HTML mode, but neither the new body nor the original message contains HTML") } inlineSpecs, err := parseInlineSpecs(inlineFlag) if err != nil { @@ -242,9 +270,11 @@ var MailForward = common.Shortcut{ var composedHTMLBody string var composedTextBody string var srcInlineBytes int64 + // Lint findings flowing into the writing-path stdout envelope. + lintApplied, lintBlocked := emptyLintEnvelopeFields() if useHTML { if err := validateInlineImageURLs(sourceMsg); err != nil { - return fmt.Errorf("forward blocked: %w", err) + return mailDecorateProblemMessage(err, "forward blocked") } processedBody := buildBodyDiv(body, bodyIsHTML(body)) origLargeAttCard := stripLargeAttachmentCard(&orig) @@ -261,12 +291,19 @@ var MailForward = common.Shortcut{ } resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(processedBody) if resolveErr != nil { - return resolveErr + return mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr) } bodyWithSig := resolved if sigResult != nil { bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent) } + // Writing-path lint: lint user-authored body + signature, NOT the + // forward quote / large-attachment card derived from the original + // message (re-linting quote blocks risks dropping allow-listed + // Feishu-native quote markup). + cleaned, rep := runWritePathLint(bodyWithSig) + bodyWithSig = cleaned + lintApplied, lintBlocked = rep.Applied, rep.Blocked composedHTMLBody = bodyWithSig + origLargeAttCard + forwardQuote bld = bld.HTMLBody([]byte(composedHTMLBody)) bld = addSignatureImagesToBuilder(bld, sigResult) @@ -327,7 +364,7 @@ var MailForward = common.Shortcut{ } content, err := downloadAttachmentContent(runtime, att.DownloadURL) if err != nil { - return fmt.Errorf("failed to download original attachment %s: %w", att.Filename, err) + return mailDecorateProblemMessage(err, "failed to download original attachment %s", att.Filename) } contentType := att.ContentType if contentType == "" { @@ -361,13 +398,13 @@ var MailForward = common.Shortcut{ } for _, f := range userFiles { if f.Size > MaxLargeAttachmentSize { - return output.ErrValidation("attachment %s (%.1f GB) exceeds the %.0f GB single file limit", + return mailFailedPreconditionError("attachment %s (%.1f GB) exceeds the %.0f GB single file limit", f.FileName, float64(f.Size)/1024/1024/1024, float64(MaxLargeAttachmentSize)/1024/1024/1024) } } totalCount := len(origAtts) + len(largeAttIDs) + len(userFiles) if totalCount > MaxAttachmentCount { - return output.ErrValidation("attachment count %d exceeds the limit of %d", totalCount, MaxAttachmentCount) + return mailFailedPreconditionError("attachment count %d exceeds the limit of %d", totalCount, MaxAttachmentCount) } allFiles = append(allFiles, userFiles...) classified := classifyAttachments(allFiles, emlBase) @@ -393,7 +430,7 @@ var MailForward = common.Shortcut{ // Upload oversized attachments as large attachments. if len(classified.Oversized) > 0 { if composedHTMLBody == "" && composedTextBody == "" { - return output.ErrValidation("large attachments require a body; " + + return mailFailedPreconditionError("large attachments require a body; " + "empty messages cannot include the download link") } if runtime.Config == nil || runtime.UserOpenId() == "" { @@ -401,7 +438,7 @@ var MailForward = common.Shortcut{ for _, f := range classified.Oversized { totalBytes += f.Size } - return output.ErrValidation("total attachment size %.1f MB exceeds the 25 MB EML limit; "+ + return mailFailedPreconditionError("total attachment size %.1f MB exceeds the 25 MB EML limit; "+ "large attachment upload requires user identity (--as user)", float64(totalBytes)/1024/1024) } @@ -466,29 +503,36 @@ var MailForward = common.Shortcut{ if len(mergedLargeAttIDs) > 0 { idsJSON, err := json.Marshal(mergedLargeAttIDs) if err != nil { - return fmt.Errorf("failed to encode large attachment IDs: %w", err) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to encode large attachment IDs: %v", err).WithCause(err) } bld = bld.Header(draftpkg.LargeAttachmentIDsHeader, base64.StdEncoding.EncodeToString(idsJSON)) } rawEML, err := bld.BuildBase64URL() if err != nil { - return fmt.Errorf("failed to build EML: %w", err) + return mailValidationError("failed to build EML: %v", err).WithCause(err) } draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML) if err != nil { - return fmt.Errorf("failed to create draft: %w", err) + return mailDecorateProblemMessage(err, "failed to create draft") } + showLintDetails := runtime.Bool("show-lint-details") if !confirmSend { - runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil) + out := buildDraftSavedOutput(draftResult, mailboxID) + applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails) + addComposeHint(out) + runtime.Out(out, nil) hintSendDraft(runtime, mailboxID, draftResult.DraftID) return nil } resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime) if err != nil { - return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftResult.DraftID, err) + return mailDecorateProblemMessage(err, "failed to send forward (draft %s created but not sent)", draftResult.DraftID) } - runtime.Out(buildDraftSendOutput(resData, mailboxID), nil) + out := buildDraftSendOutput(resData, mailboxID) + applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails) + addComposeHint(out) + runtime.Out(out, nil) hintMarkAsRead(runtime, mailboxID, messageId) return nil }, diff --git a/shortcuts/mail/mail_reply.go b/shortcuts/mail/mail_reply.go index a7674295e..161380ecd 100644 --- a/shortcuts/mail/mail_reply.go +++ b/shortcuts/mail/mail_reply.go @@ -8,10 +8,10 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" "github.com/larksuite/cli/shortcuts/mail/emlbuilder" + "github.com/larksuite/cli/shortcuts/mail/signature" ) // MailReply is the `+reply` shortcut: reply to the sender of a message, @@ -24,9 +24,11 @@ var MailReply = common.Shortcut{ Risk: "write", Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, AuthTypes: []string{"user"}, + HasFormat: true, Flags: []common.Flag{ {Name: "message-id", Desc: "Required. Message ID to reply to", Required: true}, - {Name: "body", Desc: "Reply body. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the reply body and the original message. Use --plain-text to force plain-text mode. Required unless --template-id supplies a non-empty body."}, + {Name: "body", Desc: "Reply body. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the reply body and the original message. Use --plain-text to force plain-text mode. Mutually exclusive with --body-file. Required unless --template-id supplies a non-empty body."}, + bodyFileFlag, {Name: "from", Desc: "Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. Defaults to the mailbox's primary address."}, {Name: "mailbox", Desc: "Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."}, {Name: "to", Desc: "Additional To address(es), comma-separated (appended to original sender's address)"}, @@ -41,8 +43,10 @@ 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}, + eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag, + showLintDetailsFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { messageId := runtime.Str("message-id") confirmSend := runtime.Bool("confirm-send") @@ -70,8 +74,17 @@ var MailReply = common.Shortcut{ return err } hasTemplate := runtime.Str("template-id") != "" - if !hasTemplate && strings.TrimSpace(runtime.Str("body")) == "" { - return output.ErrValidation("--body is required; pass the reply body (or use --template-id)") + bodyFlag := runtime.Str("body") + bodyFile := strings.TrimSpace(runtime.Str("body-file")) + if err := validateBodyFileMutex(bodyFlag, bodyFile, runtime.ValidatePath); err != nil { + return err + } + body, bErr := resolveBodyFromFlags(runtime) + if bErr != nil { + return bErr + } + if err := validateRequiredResolvedBody(body, hasTemplate, "--body or --body-file is required; pass the reply body (or use --template-id)"); err != nil { + return err } if err := validateConfirmSendScope(runtime); err != nil { return err @@ -95,7 +108,10 @@ var MailReply = common.Shortcut{ }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { messageId := runtime.Str("message-id") - body := runtime.Str("body") + body, bErr := resolveBodyFromFlags(runtime) + if bErr != nil { + return bErr + } toFlag := runtime.Str("to") ccFlag := runtime.Str("cc") bccFlag := runtime.Str("bcc") @@ -117,13 +133,28 @@ var MailReply = common.Shortcut{ signatureID := runtime.Str("signature-id") mailboxID := resolveComposeMailboxID(runtime) + noSignature := runtime.Bool("no-signature") + if noSignature { + if signatureID != "" { + fmt.Fprintf(runtime.IO().ErrOut, + "warning: --signature-id ignored because --no-signature is set\n") + } + signatureID = "" + } else if signatureID == "" && !plainText { + if resp, lErr := signature.ListAll(runtime, mailboxID); lErr == nil { + signatureID = signature.DefaultReplyID(resp.Usages, runtime.Str("from")) + } else { + fmt.Fprintf(runtime.IO().ErrOut, + "warning: failed to fetch default signature: %v\n", lErr) + } + } 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 fmt.Errorf("failed to fetch original message: %w", err) + return mailDecorateProblemMessage(err, "failed to fetch original message") } orig := sourceMsg.Original stripLargeAttachmentCard(&orig) @@ -199,7 +230,7 @@ var MailReply = common.Shortcut{ useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil) if strings.TrimSpace(inlineFlag) != "" && !useHTML { - return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML") + return mailValidationParamError("--inline", "--inline requires HTML mode, but neither the new body nor the original message contains HTML") } var bodyStr string if useHTML { @@ -244,9 +275,13 @@ var MailReply = common.Shortcut{ var composedHTMLBody string var composedTextBody string var srcInlineBytes int64 + // Lint findings flowing into the writing-path stdout envelope. + // Initialise empty (non-nil) so the envelope always carries + // `lint_applied[]` / `original_blocked[]` even on the plain-text path. + lintApplied, lintBlocked := emptyLintEnvelopeFields() if useHTML { if err := validateInlineImageURLs(sourceMsg); err != nil { - return fmt.Errorf("HTML reply blocked: %w", err) + return mailDecorateProblemMessage(err, "HTML reply blocked") } var srcCIDs []string bld, srcCIDs, srcInlineBytes, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages) @@ -255,12 +290,21 @@ var MailReply = common.Shortcut{ } resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(bodyStr) if resolveErr != nil { - return resolveErr + return mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr) } bodyWithSig := resolved if sigResult != nil { bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent) } + // Writing-path lint: operate on the user-authored body + signature + // ONLY — NOT on `quoted` (the
derived from the + // original message). Double-sanitising risks dropping legitimate + // Lark quote markup such as adit-html-block* / history-quote-* / + // lark-mail-doc-quote (these classes are intentionally allow-listed + // in the tag classification "通过" row). + cleaned, rep := runWritePathLint(bodyWithSig) + bodyWithSig = cleaned + lintApplied, lintBlocked = rep.Applied, rep.Blocked composedHTMLBody = bodyWithSig + quoted bld = bld.HTMLBody([]byte(composedHTMLBody)) bld = addSignatureImagesToBuilder(bld, sigResult) @@ -309,23 +353,30 @@ var MailReply = common.Shortcut{ } rawEML, err := bld.BuildBase64URL() if err != nil { - return fmt.Errorf("failed to build EML: %w", err) + return mailValidationError("failed to build EML: %v", err).WithCause(err) } draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML) if err != nil { - return fmt.Errorf("failed to create draft: %w", err) + return mailDecorateProblemMessage(err, "failed to create draft") } + showLintDetails := runtime.Bool("show-lint-details") if !confirmSend { - runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil) + out := buildDraftSavedOutput(draftResult, mailboxID) + applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails) + addComposeHint(out) + runtime.Out(out, nil) hintSendDraft(runtime, mailboxID, draftResult.DraftID) return nil } resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime) if err != nil { - return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftResult.DraftID, err) + return mailDecorateProblemMessage(err, "failed to send reply (draft %s created but not sent)", draftResult.DraftID) } - runtime.Out(buildDraftSendOutput(resData, mailboxID), nil) + out := buildDraftSendOutput(resData, mailboxID) + applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails) + addComposeHint(out) + runtime.Out(out, nil) hintMarkAsRead(runtime, mailboxID, messageId) return nil }, diff --git a/shortcuts/mail/mail_reply_all.go b/shortcuts/mail/mail_reply_all.go index e7eaf02ce..9a1039cf9 100644 --- a/shortcuts/mail/mail_reply_all.go +++ b/shortcuts/mail/mail_reply_all.go @@ -8,10 +8,10 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" "github.com/larksuite/cli/shortcuts/mail/emlbuilder" + "github.com/larksuite/cli/shortcuts/mail/signature" ) // MailReplyAll is the `+reply-all` shortcut: reply to the sender plus all @@ -24,9 +24,11 @@ var MailReplyAll = common.Shortcut{ Risk: "write", Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, AuthTypes: []string{"user"}, + HasFormat: true, Flags: []common.Flag{ {Name: "message-id", Desc: "Required. Message ID to reply to all recipients", Required: true}, - {Name: "body", Desc: "Reply body. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the reply body and the original message. Use --plain-text to force plain-text mode. Required unless --template-id supplies a non-empty body."}, + {Name: "body", Desc: "Reply body. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the reply body and the original message. Use --plain-text to force plain-text mode. Mutually exclusive with --body-file. Required unless --template-id supplies a non-empty body."}, + bodyFileFlag, {Name: "from", Desc: "Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. Defaults to the mailbox's primary address."}, {Name: "mailbox", Desc: "Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."}, {Name: "to", Desc: "Additional To address(es), comma-separated (appended to original recipients)"}, @@ -42,8 +44,10 @@ 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}, + eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag, + showLintDetailsFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { messageId := runtime.Str("message-id") confirmSend := runtime.Bool("confirm-send") @@ -71,8 +75,17 @@ var MailReplyAll = common.Shortcut{ return err } hasTemplate := runtime.Str("template-id") != "" - if !hasTemplate && strings.TrimSpace(runtime.Str("body")) == "" { - return output.ErrValidation("--body is required; pass the reply body (or use --template-id)") + bodyFlag := runtime.Str("body") + bodyFile := strings.TrimSpace(runtime.Str("body-file")) + if err := validateBodyFileMutex(bodyFlag, bodyFile, runtime.ValidatePath); err != nil { + return err + } + body, bErr := resolveBodyFromFlags(runtime) + if bErr != nil { + return bErr + } + if err := validateRequiredResolvedBody(body, hasTemplate, "--body or --body-file is required; pass the reply body (or use --template-id)"); err != nil { + return err } if err := validateConfirmSendScope(runtime); err != nil { return err @@ -96,7 +109,10 @@ var MailReplyAll = common.Shortcut{ }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { messageId := runtime.Str("message-id") - body := runtime.Str("body") + body, bErr := resolveBodyFromFlags(runtime) + if bErr != nil { + return bErr + } toFlag := runtime.Str("to") ccFlag := runtime.Str("cc") bccFlag := runtime.Str("bcc") @@ -119,13 +135,28 @@ var MailReplyAll = common.Shortcut{ signatureID := runtime.Str("signature-id") mailboxID := resolveComposeMailboxID(runtime) + noSignature := runtime.Bool("no-signature") + if noSignature { + if signatureID != "" { + fmt.Fprintf(runtime.IO().ErrOut, + "warning: --signature-id ignored because --no-signature is set\n") + } + signatureID = "" + } else if signatureID == "" && !plainText { + if resp, lErr := signature.ListAll(runtime, mailboxID); lErr == nil { + signatureID = signature.DefaultReplyID(resp.Usages, runtime.Str("from")) + } else { + fmt.Fprintf(runtime.IO().ErrOut, + "warning: failed to fetch default signature: %v\n", lErr) + } + } 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 fmt.Errorf("failed to fetch original message: %w", err) + return mailDecorateProblemMessage(err, "failed to fetch original message") } orig := sourceMsg.Original stripLargeAttachmentCard(&orig) @@ -212,7 +243,7 @@ var MailReplyAll = common.Shortcut{ useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil) if strings.TrimSpace(inlineFlag) != "" && !useHTML { - return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML") + return mailValidationParamError("--inline", "--inline requires HTML mode, but neither the new body nor the original message contains HTML") } var bodyStr string if useHTML { @@ -253,9 +284,11 @@ var MailReplyAll = common.Shortcut{ var composedHTMLBody string var composedTextBody string var srcInlineBytes int64 + // Lint findings flowing into the writing-path stdout envelope. + lintApplied, lintBlocked := emptyLintEnvelopeFields() if useHTML { if err := validateInlineImageURLs(sourceMsg); err != nil { - return fmt.Errorf("HTML reply-all blocked: %w", err) + return mailDecorateProblemMessage(err, "HTML reply-all blocked") } var srcCIDs []string bld, srcCIDs, srcInlineBytes, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages) @@ -264,12 +297,19 @@ var MailReplyAll = common.Shortcut{ } resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(bodyStr) if resolveErr != nil { - return resolveErr + return mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr) } bodyWithSig := resolved if sigResult != nil { bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent) } + // Writing-path lint: same pattern as +reply — operate on bodyWithSig + // only; the `quoted` block from the original message must NOT be + // re-linted (it may contain Feishu-native quote-block classes that + // the lint allow-list intentionally permits in pass-through). + cleaned, rep := runWritePathLint(bodyWithSig) + bodyWithSig = cleaned + lintApplied, lintBlocked = rep.Applied, rep.Blocked composedHTMLBody = bodyWithSig + quoted bld = bld.HTMLBody([]byte(composedHTMLBody)) bld = addSignatureImagesToBuilder(bld, sigResult) @@ -318,23 +358,30 @@ var MailReplyAll = common.Shortcut{ } rawEML, err := bld.BuildBase64URL() if err != nil { - return fmt.Errorf("failed to build EML: %w", err) + return mailValidationError("failed to build EML: %v", err).WithCause(err) } draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML) if err != nil { - return fmt.Errorf("failed to create draft: %w", err) + return mailDecorateProblemMessage(err, "failed to create draft") } + showLintDetails := runtime.Bool("show-lint-details") if !confirmSend { - runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil) + out := buildDraftSavedOutput(draftResult, mailboxID) + applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails) + addComposeHint(out) + runtime.Out(out, nil) hintSendDraft(runtime, mailboxID, draftResult.DraftID) return nil } resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime) if err != nil { - return fmt.Errorf("failed to send reply-all (draft %s created but not sent): %w", draftResult.DraftID, err) + return mailDecorateProblemMessage(err, "failed to send reply-all (draft %s created but not sent)", draftResult.DraftID) } - runtime.Out(buildDraftSendOutput(resData, mailboxID), nil) + out := buildDraftSendOutput(resData, mailboxID) + applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails) + addComposeHint(out) + runtime.Out(out, nil) hintMarkAsRead(runtime, mailboxID, messageId) return nil }, diff --git a/shortcuts/mail/mail_send.go b/shortcuts/mail/mail_send.go index 9ea3b422a..0f8586600 100644 --- a/shortcuts/mail/mail_send.go +++ b/shortcuts/mail/mail_send.go @@ -8,10 +8,10 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" "github.com/larksuite/cli/shortcuts/mail/emlbuilder" + "github.com/larksuite/cli/shortcuts/mail/signature" ) // MailSend is the `+send` shortcut: compose a new email and save it as a @@ -23,10 +23,12 @@ var MailSend = common.Shortcut{ Risk: "write", Scopes: []string{"mail:user_mailbox.message:send", "mail:user_mailbox.message:modify", "mail:user_mailbox:readonly"}, AuthTypes: []string{"user"}, + HasFormat: true, Flags: []common.Flag{ {Name: "to", Desc: "Recipient email address(es), comma-separated"}, {Name: "subject", Desc: "Email subject. Required unless --template-id supplies a non-empty subject."}, - {Name: "body", Desc: "Email body. Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --plain-text to force plain-text mode. Required unless --template-id supplies a non-empty body."}, + {Name: "body", Desc: "Email body. Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --plain-text to force plain-text mode. Mutually exclusive with --body-file. Required unless --template-id supplies a non-empty body."}, + bodyFileFlag, {Name: "from", Desc: "Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. Defaults to the mailbox's primary address."}, {Name: "mailbox", Desc: "Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."}, {Name: "cc", Desc: "CC email address(es), comma-separated"}, @@ -39,8 +41,10 @@ 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}, + eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag, + showLintDetailsFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { to := runtime.Str("to") subject := runtime.Str("subject") @@ -74,11 +78,13 @@ var MailSend = common.Shortcut{ return err } hasTemplate := runtime.Str("template-id") != "" - if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" { - return output.ErrValidation("--subject is required; pass the final email subject (or use --template-id)") + bodyFlag := runtime.Str("body") + bodyFile := strings.TrimSpace(runtime.Str("body-file")) + if err := validateBodyFileMutex(bodyFlag, bodyFile, runtime.ValidatePath); err != nil { + return err } - if !hasTemplate && strings.TrimSpace(runtime.Str("body")) == "" { - return output.ErrValidation("--body is required; pass the full email body (or use --template-id)") + if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" { + return mailValidationParamError("--subject", "--subject is required; pass the final email subject (or use --template-id)") } // With --template-id, tos/ccs/bccs may come from the template, so // defer the at-least-one-recipient check to Execute (after @@ -97,7 +103,19 @@ var MailSend = common.Shortcut{ if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil { return err } - if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil { + // Resolve the body content first (reading --body-file if set) so + // inline / HTML checks see the actual body. This makes the + // `--body-file plain.txt --inline …` combination fail validation + // the same way `--body 'plain' --inline …` already does, instead + // of silently dropping the inline images at Execute (Major #4). + body, bErr := resolveBodyFromFlags(runtime) + if bErr != nil { + return bErr + } + if err := validateRequiredResolvedBody(body, hasTemplate, "--body or --body-file is required; pass the full email body (or use --template-id)"); err != nil { + return err + } + if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), body); err != nil { return err } if err := validateEventFlags(runtime); err != nil { @@ -108,7 +126,10 @@ var MailSend = common.Shortcut{ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { to := runtime.Str("to") subject := runtime.Str("subject") - body := runtime.Str("body") + body, err := resolveBodyFromFlags(runtime) + if err != nil { + return err + } ccFlag := runtime.Str("cc") bccFlag := runtime.Str("bcc") plainText := runtime.Bool("plain-text") @@ -176,6 +197,21 @@ var MailSend = common.Shortcut{ } } + noSignature := runtime.Bool("no-signature") + if noSignature { + if signatureID != "" { + fmt.Fprintf(runtime.IO().ErrOut, + "warning: --signature-id ignored because --no-signature is set\n") + } + signatureID = "" + } else if signatureID == "" && !plainText { + if resp, lErr := signature.ListAll(runtime, mailboxID); lErr == nil { + signatureID = signature.DefaultSendID(resp.Usages, senderEmail) + } else { + fmt.Fprintf(runtime.IO().ErrOut, + "warning: failed to fetch default signature: %v\n", lErr) + } + } sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail) if err != nil { return err @@ -206,6 +242,10 @@ var MailSend = common.Shortcut{ var autoResolvedPaths []string var composedHTMLBody string var composedTextBody string + // Lint findings flowing into the writing-path stdout envelope. + // Initialised as empty (non-nil) slices so the envelope always carries + // `lint_applied[]` / `original_blocked[]` even on the plain-text path. + lintApplied, lintBlocked := emptyLintEnvelopeFields() if plainText { composedTextBody = body bld = bld.TextBody([]byte(composedTextBody)) @@ -217,9 +257,17 @@ var MailSend = common.Shortcut{ } resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody) if resolveErr != nil { - return resolveErr + return mailValidationError("failed to resolve local image paths: %v", resolveErr).WithCause(resolveErr) } resolved = injectSignatureIntoBody(resolved, sigResult) + // Writing-path lint: AutoFix=true / Strict=false — the writing-path + // safety contract has no `--no-lint` opt-out. Runs AFTER + // applyTemplate (above) + ResolveLocalImagePaths + + // injectSignatureIntoBody so the lint sees the final HTML the + // recipient renderer will see. + cleanedHTML, rep := runWritePathLint(resolved) + resolved = cleanedHTML + lintApplied, lintBlocked = rep.Applied, rep.Blocked composedHTMLBody = resolved bld = bld.HTMLBody([]byte(composedHTMLBody)) bld = addSignatureImagesToBuilder(bld, sigResult) @@ -276,23 +324,30 @@ var MailSend = common.Shortcut{ rawEML, err := bld.BuildBase64URL() if err != nil { - return fmt.Errorf("failed to build EML: %w", err) + return mailValidationError("failed to build EML: %v", err).WithCause(err) } draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML) if err != nil { - return fmt.Errorf("failed to create draft: %w", err) + return mailDecorateProblemMessage(err, "failed to create draft") } + showLintDetails := runtime.Bool("show-lint-details") if !confirmSend { - runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil) + out := buildDraftSavedOutput(draftResult, mailboxID) + applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails) + addComposeHint(out) + runtime.Out(out, nil) hintSendDraft(runtime, mailboxID, draftResult.DraftID) return nil } resData, err := draftpkg.Send(runtime, mailboxID, draftResult.DraftID, sendTime) if err != nil { - return fmt.Errorf("failed to send email (draft %s created but not sent): %w", draftResult.DraftID, err) + return mailDecorateProblemMessage(err, "failed to send email (draft %s created but not sent)", draftResult.DraftID) } - runtime.Out(buildDraftSendOutput(resData, mailboxID), nil) + out := buildDraftSendOutput(resData, mailboxID) + applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails) + addComposeHint(out) + runtime.Out(out, nil) return nil }, } diff --git a/shortcuts/mail/signature/provider.go b/shortcuts/mail/signature/provider.go index 1dda66960..98685e431 100644 --- a/shortcuts/mail/signature/provider.go +++ b/shortcuts/mail/signature/provider.go @@ -5,9 +5,10 @@ package signature import ( "encoding/json" - "fmt" "net/url" + "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -27,19 +28,19 @@ func ListAll(runtime *common.RuntimeContext, mailboxID string) (*GetSignaturesRe return cached, nil } - data, err := runtime.CallAPI("GET", signaturesPath(mailboxID), nil, nil) + data, err := runtime.CallAPITyped("GET", signaturesPath(mailboxID), nil, nil) if err != nil { - return nil, fmt.Errorf("get signatures: %w", err) + return nil, err } raw, err := json.Marshal(data) if err != nil { - return nil, fmt.Errorf("get signatures: marshal response: %w", err) + return nil, errs.NewInternalError(errs.SubtypeSDKError, "get signatures: marshal response: %v", err).WithCause(err) } var resp GetSignaturesResponse if err := json.Unmarshal(raw, &resp); err != nil { - return nil, fmt.Errorf("get signatures: unmarshal response: %w", err) + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "get signatures: unmarshal response: %v", err).WithCause(err) } processCache[mailboxID] = &resp @@ -66,5 +67,41 @@ func Get(runtime *common.RuntimeContext, mailboxID, signatureID string) (*Signat return &resp.Signatures[i], nil } } - return nil, fmt.Errorf("signature not found: %s", signatureID) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "signature not found: %s", signatureID) +} + +// DefaultSendID returns the send_mail_signature_id for the given addr. +// Falls back to usages[0] if no entry matches, but returns "" when +// no default is configured (id empty or "0"). +// Returns "" if usages is empty. +func DefaultSendID(usages []SignatureUsage, addr string) string { + return pickSignatureID(usages, addr, func(u SignatureUsage) string { + return u.SendMailSignatureID + }) +} + +// DefaultReplyID returns the reply_signature_id for the given addr. +// Used by reply/reply-all/forward shortcuts. +// Returns "" if usages is empty or no default is configured. +func DefaultReplyID(usages []SignatureUsage, addr string) string { + return pickSignatureID(usages, addr, func(u SignatureUsage) string { + return u.ReplySignatureID + }) +} + +func pickSignatureID(usages []SignatureUsage, addr string, pick func(SignatureUsage) string) string { + if len(usages) == 0 { + return "" + } + laddr := strings.ToLower(strings.TrimSpace(addr)) + for _, u := range usages { + if strings.ToLower(strings.TrimSpace(u.EmailAddress)) == laddr { + id := pick(u) + if id == "" || id == "0" { + return "" + } + return id + } + } + return "" } diff --git a/shortcuts/mail/signature_compose.go b/shortcuts/mail/signature_compose.go index fc1760d1f..c367b98b0 100644 --- a/shortcuts/mail/signature_compose.go +++ b/shortcuts/mail/signature_compose.go @@ -5,7 +5,6 @@ package mail import ( "context" - "fmt" "io" "net/http" "net/url" @@ -13,6 +12,7 @@ import ( "strings" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" "github.com/larksuite/cli/shortcuts/mail/emlbuilder" @@ -25,6 +25,13 @@ var signatureFlag = common.Flag{ Desc: "Optional. Signature ID to append after body content. Run `mail +signature` to list available signatures.", } +// noSignatureFlag is the common flag for --no-signature, shared by all compose shortcuts. +var noSignatureFlag = common.Flag{ + Name: "no-signature", + Type: "bool", + Desc: "Do not append any signature to the email body (overrides automatic default signature).", +} + // signatureResult holds the pre-processed signature data ready for HTML injection. type signatureResult struct { ID string @@ -32,8 +39,6 @@ type signatureResult struct { Images []draftpkg.SignatureImage } -// resolveSignature fetches, interpolates, and downloads images for a signature. -// Returns nil if signatureID is empty. // resolveSignature fetches, interpolates, and 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. @@ -61,7 +66,7 @@ func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailb } data, ct, err := downloadSignatureImage(runtime, img.DownloadURL, img.ImageName) if err != nil { - return nil, fmt.Errorf("failed to download signature image %s: %w", img.ImageName, err) + return nil, mailDecorateProblemMessage(err, "failed to download signature image %s", img.ImageName) } images = append(images, draftpkg.SignatureImage{ CID: img.CID, @@ -109,13 +114,12 @@ func addSignatureImagesToBuilder(bld emlbuilder.Builder, sig *signatureResult) e return bld } -// resolveSenderInfo fetches senderName and senderEmail via the send_as API. // resolveSenderInfo fetches send_as addresses and returns the name/email // for signature interpolation. If fromEmail is non-empty, it matches // that address in the sendable list (for alias/send_as scenarios); // otherwise falls back to the first (primary) address. func resolveSenderInfo(runtime *common.RuntimeContext, mailboxID, fromEmail string) (name, email string) { - data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "settings", "send_as"), nil, nil) + data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "settings", "send_as"), nil, nil) if err != nil { return "", "" } @@ -154,45 +158,54 @@ func resolveSenderInfo(runtime *common.RuntimeContext, mailboxID, fromEmail stri func downloadSignatureImage(runtime *common.RuntimeContext, downloadURL, filename string) ([]byte, string, error) { u, err := url.Parse(downloadURL) if err != nil { - return nil, "", fmt.Errorf("signature image download: invalid URL: %w", err) + return nil, "", mailInvalidResponseError("signature image download: invalid URL: %v", err).WithCause(err) } if u.Scheme != "https" { - return nil, "", fmt.Errorf("signature image download: URL must use https (got %q)", u.Scheme) + return nil, "", mailInvalidResponseError("signature image download: URL must use https (got %q)", u.Scheme) } if u.Host == "" { - return nil, "", fmt.Errorf("signature image download: URL has no host") + return nil, "", mailInvalidResponseError("signature image download: URL has no host") } httpClient, err := runtime.Factory.HttpClient() if err != nil { - return nil, "", fmt.Errorf("signature image download: %w", err) + return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "signature image download: %v", err).WithCause(err) } ctx, cancel := context.WithTimeout(runtime.Ctx(), 30*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) if err != nil { - return nil, "", fmt.Errorf("signature image download: %w", err) + return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "signature image download: %v", err).WithCause(err) } // Do NOT send Authorization: the download URL is pre-signed. resp, err := httpClient.Do(req) if err != nil { - return nil, "", fmt.Errorf("signature image download: %w", err) + return nil, "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "signature image download: %v", err).WithCause(err) } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return nil, "", fmt.Errorf("signature image download: HTTP %d: %s", resp.StatusCode, string(body)) + if resp.StatusCode >= 500 { + return nil, "", errs.NewNetworkError(errs.SubtypeNetworkServer, "signature image download: HTTP %d: %s", resp.StatusCode, string(body)). + WithCode(resp.StatusCode). + WithRetryable() + } + subtype := errs.SubtypeUnknown + if resp.StatusCode == http.StatusNotFound { + subtype = errs.SubtypeNotFound + } + return nil, "", errs.NewAPIError(subtype, "signature image download: HTTP %d: %s", resp.StatusCode, string(body)).WithCode(resp.StatusCode) } const maxSize = 10 * 1024 * 1024 data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1)) if err != nil { - return nil, "", fmt.Errorf("signature image download: read body: %w", err) + return nil, "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "signature image download: read body: %v", err).WithCause(err) } if len(data) > maxSize { - return nil, "", fmt.Errorf("signature image download: file exceeds 10MB limit") + return nil, "", mailFailedPreconditionError("signature image download: file exceeds 10MB limit") } ct := resp.Header.Get("Content-Type") @@ -241,7 +254,11 @@ func signatureCIDs(sig *signatureResult) []string { // validateSignatureWithPlainText returns an error if both --plain-text and --signature-id are set. func validateSignatureWithPlainText(plainText bool, signatureID string) error { if plainText && signatureID != "" { - return fmt.Errorf("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode") + 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 } diff --git a/skills/lark-mail/references/lark-mail-draft-create.md b/skills/lark-mail/references/lark-mail-draft-create.md index eeb016af9..52cdb1e46 100644 --- a/skills/lark-mail/references/lark-mail-draft-create.md +++ b/skills/lark-mail/references/lark-mail-draft-create.md @@ -8,6 +8,8 @@ 如需修改已有草稿,不要使用此命令,请使用 `lark-cli mail +draft-edit`。 +**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范** + ## 安全约束 此命令创建草稿——**不会**发送邮件。用户可以在飞书邮件 UI 中打开草稿查看详情,确认后再进入后续操作。因此: @@ -44,7 +46,8 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te |------|------|------| | `--to ` | 否 | 完整收件人列表,多个用逗号分隔。支持 `Alice ` 格式。省略时草稿不带收件人(之后可通过 `+draft-edit` 添加) | | `--subject ` | 是 | 草稿主题 | -| `--body ` | 是 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) | +| `--body ` | 二选一 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径)。与 `--body-file` 互斥 | +| `--body-file ` | 二选一 | 从文件读取邮件正文 HTML(相对路径,仅限 cwd 子树)。与 `--body` 互斥。文件大小上限 32 MB | | `--from ` | 否 | 发件人邮箱地址(EML From 头)。使用别名(send_as)发信时,设为别名地址并配合 `--mailbox` 指定所属邮箱。省略时使用邮箱主地址 | | `--mailbox ` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用,如通过别名或 send_as 地址发信。可通过 `accessible_mailboxes` 查询可用邮箱 | | `--cc ` | 否 | 完整抄送列表,多个用逗号分隔 | @@ -53,6 +56,7 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te | `--attach ` | 否 | 附件文件路径,多个用逗号分隔。相对路径。当附件导致 EML 总大小超过 25 MB 时,超出部分自动上传为超大附件(HTML 邮件插入下载卡片,纯文本邮件追加下载链接),单个文件上限 3 GB | | `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | | `--signature-id ` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 | +| `--no-signature` | 否 | Do not append any signature to the email body (overrides automatic default signature). | | `--priority ` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 | | `--request-receipt` | 否 | 请求已读回执(RFC 3798 Message Disposition Notification)。在草稿 EML 里写 `Disposition-Notification-To: ` 头,发送时生效。收件人的邮件客户端可能弹出提示、自动发送或忽略——送达不保证 | | `--event-summary ` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请。需同时设置 `--event-start` 和 `--event-end` | diff --git a/skills/lark-mail/references/lark-mail-forward.md b/skills/lark-mail/references/lark-mail-forward.md index e2e50830c..f36e7928a 100644 --- a/skills/lark-mail/references/lark-mail-forward.md +++ b/skills/lark-mail/references/lark-mail-forward.md @@ -13,6 +13,8 @@ ## CRITICAL — 发送工作流(必须遵循) +**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范** + 此命令默认**只保存草稿**,不会发送邮件。转发会将原邮件内容发送给新收件人,需要发送时有两种合规方式: **方式 A(推荐)** — 创建转发草稿(不带 `--confirm-send`): @@ -60,7 +62,8 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run |------|------|------| | `--message-id ` | 是 | 被转发的邮件 ID | | `--to ` | 是 | 收件人邮箱,多个用逗号分隔 | -| `--body ` | 否 | 转发时附加的说明文字。推荐使用 HTML 获得富文本排版;也支持纯文本。根据转发正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) | +| `--body ` | 否 | 转发时附加的说明文字。推荐使用 HTML 获得富文本排版;也支持纯文本。根据转发正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径)。与 `--body-file` 互斥 | +| `--body-file ` | 否 | 从文件读取转发说明 HTML(相对路径,仅限 cwd 子树)。与 `--body` 互斥。文件大小上限 32 MB | | `--from ` | 否 | 发件人邮箱地址(EML From 头)。使用别名(send_as)发信时,设为别名地址并配合 `--mailbox` 指定所属邮箱。默认读取邮箱主地址 | | `--mailbox ` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用。可通过 `accessible_mailboxes` 查询可用邮箱 | | `--cc ` | 否 | 抄送邮箱,多个用逗号分隔 | @@ -69,6 +72,7 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run | `--attach ` | 否 | 附件文件路径,多个用逗号分隔,追加在原邮件附件之后。相对路径。当附件导致 EML 总大小超过 25 MB 时,超出部分自动上传为超大附件(HTML 邮件插入下载卡片,纯文本邮件追加下载链接),单个文件上限 3 GB | | `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | | `--signature-id ` | 否 | 签名 ID。附加邮箱签名到转发正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 | +| `--no-signature` | 否 | Do not append any signature to the email body (overrides automatic default signature). | | `--priority ` | 否 | 邮件优先级:`high`、`normal`、`low`。省略或 `normal` 时不设置优先级 | | `--event-summary ` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请。需同时设置 `--event-start` 和 `--event-end` | | `--event-start