Files
larksuite-cli/shortcuts/mail/mail_draft_create.go
xzcong0820 cf35d1e499 feat(mail): auto-attach default signature on send/reply/forward (#1415)
* feat(mail): auto-attach default signature on send/reply/forward

- Add exported PlainTextFromHTML wrapper in draft/htmltext.go
- Add DefaultSendID/DefaultReplyID in signature/provider.go
- Add noSignatureFlag, autoResolveSignatureID, validateNoSignatureConflict,
  injectPlainTextSignature in signature_compose.go; remove validateSignatureWithPlainText
- mail_send, mail_draft_create: add --no-signature flag, auto-resolve default
  signature when no --signature-id given, inject plain-text sig in plain-text branch
- mail_reply, mail_reply_all, mail_forward: same flag/validate changes + timing fix
  (resolveSignature moved to after senderEmail is finalized)
- Update 5 reference docs: add --no-signature row, update --plain-text and
  --signature-id descriptions

---------

Co-authored-by: xzcong0820 <278082089+xzcong0820@users.noreply.github.com>
2026-06-15 20:04:05 +08:00

388 lines
18 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"fmt"
"io"
"strings"
"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"
)
// draftCreateInput bundles all +draft-create user flags into a single
// struct so parseDraftCreateInput / buildRawEMLForDraftCreate have a
// uniform value type to pass around.
type draftCreateInput struct {
To string
Subject string
Body string
From string
CC string
BCC string
Attach string
Inline string
PlainText bool
}
// MailDraftCreate is the `+draft-create` shortcut: create a brand-new mail
// draft from scratch. For reply drafts use +reply; for forward drafts use
// +forward.
var MailDraftCreate = common.Shortcut{
Service: "mail",
Command: "+draft-create",
Description: "Create a brand-new mail draft from scratch (NOT for reply or forward). For reply drafts use +reply; for forward drafts use +forward. Only use +draft-create when composing a new email with no parent message.",
Risk: "write",
Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox:readonly"},
AuthTypes: []string{"user"},
HasFormat: true,
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. 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."},
{Name: "bcc", Desc: "Optional. Full Bcc recipient list. Separate multiple addresses with commas. Display-name format is supported."},
{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring HTML auto-detection. Cannot be used with --inline."},
{Name: "attach", Desc: "Optional. Regular attachment file paths (relative path only). Separate multiple paths with commas. Each path must point to a readable local file."},
{Name: "inline", Desc: "Optional. Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
{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)
api := common.NewDryRunAPI().
Desc("Create a new empty draft without sending it. The command resolves the sender address (from --from, --mailbox, or mailbox profile), builds a complete EML from `to/subject/body` plus any optional cc/bcc/attachment/inline inputs, and finally calls drafts.create. `--body` content type is auto-detected (HTML or plain text); use `--plain-text` to force plain-text mode. For inline images, CIDs can be any unique strings, e.g. random hex. Use the dedicated reply or forward shortcuts for reply-style drafts instead of adding reply-thread headers here.")
if tid := runtime.Str("template-id"); tid != "" {
api = api.GET(templateMailboxPath(mailboxID, tid)).
Desc("Fetch template to merge with compose flags (subject/body/to/cc/bcc/attachments).")
}
api = api.GET(mailboxPath(mailboxID, "profile")).
POST(mailboxPath(mailboxID, "drafts")).
Body(map[string]interface{}{
"raw": "<base64url-EML>",
"_preview": map[string]interface{}{
"to": runtime.Str("to"),
"subject": runtime.Str("subject"),
},
})
return api
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateTemplateID(runtime.Str("template-id")); err != nil {
return err
}
hasTemplate := runtime.Str("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("subject")) == "" {
return mailValidationParamError("--subject", "--subject is required; pass the final email subject (or use --template-id)")
}
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
return err
}
// 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)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
priority, err := parsePriority(runtime.Str("priority"))
if err != nil {
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: body,
From: runtime.Str("from"),
CC: runtime.Str("cc"),
BCC: runtime.Str("bcc"),
Attach: runtime.Str("attach"),
Inline: runtime.Str("inline"),
PlainText: runtime.Bool("plain-text"),
}
var templateLargeAttachmentIDs []string
var templateInlineAttachments []templateInlineRef
var templateSmallAttachments []templateAttachmentRef
templateID := runtime.Str("template-id")
if tid := templateID; tid != "" {
tpl, err := fetchTemplate(runtime, mailboxID, tid)
if err != nil {
return err
}
merged := applyTemplate(
templateShortcutDraftCreate, tpl,
"", "", "", "", "",
input.To, input.CC, input.BCC, input.Subject, input.Body,
)
input.To = merged.To
input.CC = merged.Cc
input.BCC = merged.Bcc
input.Subject = merged.Subject
input.Body = merged.Body
if !input.PlainText && merged.IsPlainTextMode {
input.PlainText = true
}
templateLargeAttachmentIDs = merged.LargeAttachmentIDs
templateInlineAttachments = merged.InlineAttachments
templateSmallAttachments = merged.SmallAttachments
for _, w := range merged.Warnings {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
}
inlineCount, largeCount := countAttachmentsByType(tpl.Attachments)
logTemplateInfo(runtime, "apply.draft_create", map[string]interface{}{
"mailbox_id": mailboxID,
"template_id": tid,
"is_plain_text_mode": input.PlainText,
"attachments_total": len(tpl.Attachments),
"inline_count": inlineCount,
"large_count": largeCount,
"tos_count": countAddresses(input.To),
"ccs_count": countAddresses(input.CC),
"bccs_count": countAddresses(input.BCC),
})
}
if strings.TrimSpace(input.Subject) == "" {
return mailValidationParamError("--subject", "effective subject is empty after applying template; pass --subject explicitly")
}
if strings.TrimSpace(input.Body) == "" {
return mailValidationParamError("--body", "effective body is empty after applying template; pass --body explicitly")
}
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, senderEmail)
if err != nil {
return err
}
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
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
// draft-save flows, it does not add a follow-up send tip.
fmt.Fprintf(w, "draft_id: %s\n", draftResult.DraftID)
if reference, _ := out["reference"].(string); reference != "" {
fmt.Fprintf(w, "reference: %s\n", reference)
}
})
return nil
},
}
// buildRawEMLForDraftCreate assembles a base64url-encoded EML for the
// +draft-create shortcut. It resolves the sender from runtime / input,
// validates recipient counts, applies signature templates, resolves local
// image paths to CID-referenced inline parts, enforces attachment limits,
// applies priority headers, and optionally adds the Disposition-Notification-
// To header when --request-receipt is set. senderEmail is required; empty
// 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,
input draftCreateInput,
sigResult *signatureResult,
priority string,
templateLargeAttachmentIDs []string,
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()
// 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")
}
if err := validateRecipientCount(input.To, input.CC, input.BCC); err != nil {
return "", lintApplied, lintBlocked, err
}
bld := emlbuilder.New().WithFileIO(runtime.FileIO()).
AllowNoRecipients().
Subject(input.Subject)
if strings.TrimSpace(input.To) != "" {
bld = bld.ToAddrs(parseNetAddrs(input.To))
}
if senderEmail != "" {
bld = bld.From("", senderEmail)
}
// senderEmail non-emptiness is already enforced above (L140); the flag-
// driven guard here only exists to make the relationship explicit to
// readers. requireSenderForRequestReceipt unifies this with the other
// compose shortcuts; if it ever trips in this path, the above check
// regressed.
if err := requireSenderForRequestReceipt(runtime, senderEmail); err != nil {
return "", lintApplied, lintBlocked, err
}
if runtime.Bool("request-receipt") {
bld = bld.DispositionNotificationTo("", senderEmail)
}
if input.CC != "" {
bld = bld.CCAddrs(parseNetAddrs(input.CC))
}
if input.BCC != "" {
bld = bld.BCCAddrs(parseNetAddrs(input.BCC))
}
inlineSpecs, parseErr := parseInlineSpecs(input.Inline)
if parseErr != nil {
return "", lintApplied, lintBlocked, parseErr
}
var autoResolvedPaths []string
var composedHTMLBody string
var composedTextBody string
if input.PlainText {
composedTextBody = injectPlainTextSignature(input.Body, sigResult)
bld = bld.TextBody([]byte(composedTextBody))
} else if bodyIsHTML(input.Body) || sigResult != nil {
htmlBody := input.Body
if !bodyIsHTML(input.Body) {
htmlBody = buildBodyDiv(input.Body, false)
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
if resolveErr != nil {
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)
var allCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
autoResolvedPaths = append(autoResolvedPaths, ref.FilePath)
allCIDs = append(allCIDs, ref.CID)
}
for _, spec := range inlineSpecs {
bld = bld.AddFileInline(spec.FilePath, spec.CID)
allCIDs = append(allCIDs, spec.CID)
}
allCIDs = append(allCIDs, signatureCIDs(sigResult)...)
var tplInlineCIDs []string
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 cidErr := validateInlineCIDs(resolved, allCIDs, nil); cidErr != nil {
return "", lintApplied, lintBlocked, cidErr
}
} else {
composedTextBody = input.Body
bld = bld.TextBody([]byte(composedTextBody))
}
// Embed template SMALL non-inline attachments via AddAttachment. No-op
// when the template contributes none; runs in both HTML and plain-text
// branches because regular attachments are independent of body mode.
var templateSmallBytes int64
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 {
bld = bld.CalendarBody(calData)
}
allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, 0) + templateSmallBytes
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, buildErr := bld.BuildBase64URL()
if buildErr != nil {
return "", lintApplied, lintBlocked, mailValidationError("build EML failed: %v", buildErr).WithCause(buildErr)
}
return rawEML, lintApplied, lintBlocked, nil
}