Documentation
¶
Index ¶
Constants ¶
const ( // MaxAttachmentCount is the maximum number of attachments (including original // attachments carried over in +forward) allowed per message. MaxAttachmentCount = 250 // MaxAttachmentBytes is the maximum combined size of all attachments in bytes. // Note: the overall EML size limit (emlbuilder.MaxEMLSize) is enforced separately. MaxAttachmentBytes = 25 * 1024 * 1024 // 25 MB // MaxAttachmentDownloadBytes is the safety limit for downloading a single // attachment. This is larger than MaxAttachmentBytes (which governs outgoing // composition) to allow for received attachments that exceed the send-side // limit. The purpose is to prevent unbounded memory allocation. MaxAttachmentDownloadBytes = 35 * 1024 * 1024 // 35 MB // MaxRecipientCount is the maximum total number of recipients (To + CC + BCC // combined) allowed per message. This is a defence-in-depth measure to prevent // abuse such as mass spam or mail bombing via the CLI. MaxRecipientCount = 500 )
Mail composition limits enforced before sending.
Variables ¶
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: "Required. Final draft subject. Pass the full subject you want to appear in the draft.", Required: true}, {Name: "body", Desc: "Required. 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: true}, {Name: "from", Desc: "Optional. Sender email address; also selects the mailbox to create the draft in. If omitted, the current signed-in user's primary mailbox address is used."}, {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\"."}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { input, err := parseDraftCreateInput(runtime) if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } mailboxID := resolveComposeMailboxID(runtime) return common.NewDryRunAPI(). Desc("Create a new empty draft without sending it. The command first reads the current mailbox profile to determine the default sender when `--from` is omitted, then 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."). GET(mailboxPath(mailboxID, "profile")). POST(mailboxPath(mailboxID, "drafts")). Body(map[string]interface{}{ "raw": "<base64url-EML>", "_preview": map[string]interface{}{ "to": input.To, "subject": input.Subject, }, }) }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("subject")) == "" { return output.ErrValidation("--subject is required; pass the final email subject") } if strings.TrimSpace(runtime.Str("body")) == "" { return output.ErrValidation("--body is required; pass the full email body") } if err := validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil { return err } return nil }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { input, err := parseDraftCreateInput(runtime) if err != nil { return err } rawEML, err := buildRawEMLForDraftCreate(runtime, input) if err != nil { return err } mailboxID := resolveComposeMailboxID(runtime) draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML) if err != nil { return fmt.Errorf("create draft failed: %w", err) } out := map[string]interface{}{"draft_id": draftID} runtime.OutFormat(out, nil, func(w io.Writer) { fmt.Fprintln(w, "Draft created.") fmt.Fprintf(w, "draft_id: %s\n", draftID) }) return nil }, }
var MailDraftEdit = common.Shortcut{ Service: "mail", Command: "+draft-edit", Description: "Use when updating an existing mail draft without sending it. Prefer this shortcut over calling raw drafts.get or drafts.update directly, because it performs draft-safe MIME read/patch/write editing while preserving unchanged structure, attachments, and headers where possible.", Risk: "write", Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "from", Default: "me", Desc: "Mailbox email address containing the draft (default: me)"}, {Name: "draft-id", Desc: "Target draft ID. Required for real edits. It can be omitted only when using the --print-patch-template flag by itself."}, {Name: "set-subject", Desc: "Replace the subject with this final value. Use this for full subject replacement, not for appending a fragment to the existing subject."}, {Name: "set-to", Desc: "Replace the entire To recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."}, {Name: "set-cc", Desc: "Replace the entire Cc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."}, {Name: "set-bcc", Desc: "Replace the entire Bcc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."}, {Name: "patch-file", Desc: "Edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. All body edits MUST go through --patch-file. Two body ops: set_body (full replacement including quote) and set_reply_body (replaces only user-authored content, auto-preserves quote block). Run --inspect first to check has_quoted_content, then --print-patch-template for the JSON structure. Relative path only."}, {Name: "print-patch-template", Type: "bool", Desc: "Print the JSON template and supported operations for the --patch-file flag. Recommended first step before generating a patch file. No draft read or write is performed."}, {Name: "inspect", Type: "bool", Desc: "Inspect the draft without modifying it. Returns the draft projection including subject, recipients, body summary, has_quoted_content (whether the draft contains a reply/forward quote block), attachments_summary (with part_id and cid for each attachment), and inline_summary. Run this BEFORE editing body to check has_quoted_content: if true, use set_reply_body in --patch-file to preserve the quote; if false, use set_body."}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { if runtime.Bool("print-patch-template") { return common.NewDryRunAPI(). Set("mode", "print-patch-template"). Set("template", buildDraftEditPatchTemplate()) } draftID := runtime.Str("draft-id") if draftID == "" { return common.NewDryRunAPI().Set("error", "--draft-id is required for real draft edits; only --print-patch-template can be used without a draft id") } mailboxID := resolveComposeMailboxID(runtime) if runtime.Bool("inspect") { return common.NewDryRunAPI(). Desc("Inspect a draft without modifying it: fetch the raw EML, parse it into MIME structure, and return the projection (subject, recipients, body, attachments_summary, inline_summary). No write is performed."). GET(mailboxPath(mailboxID, "drafts", draftID)). Params(map[string]interface{}{"format": "raw"}) } patch, err := buildDraftEditPatch(runtime) if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } return common.NewDryRunAPI(). Desc("Edit an existing draft without sending it: first call drafts.get(format=raw) to fetch the current EML, parse it into MIME structure, apply either direct flags or the typed patch from patch-file, re-serialize the updated draft, and then call drafts.update. This is a minimal-edit pipeline rather than a full rebuild, so unchanged headers, attachments, and MIME subtrees are preserved where possible. Body edits must go through --patch-file using set_body or set_reply_body ops. It also has no optimistic locking, so concurrent edits to the same draft are last-write-wins."). GET(mailboxPath(mailboxID, "drafts", draftID)). Params(map[string]interface{}{"format": "raw"}). PUT(mailboxPath(mailboxID, "drafts", draftID)). Body(map[string]interface{}{ "raw": "<base64url-EML>", "_patch": patch.Summary(), "_notice": "This edit flow has no optimistic locking. If the same draft is changed concurrently, the last writer wins.", }) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { if runtime.Bool("print-patch-template") { runtime.Out(buildDraftEditPatchTemplate(), nil) return nil } draftID := runtime.Str("draft-id") if draftID == "" { return output.ErrValidation("--draft-id is required for real draft edits; if you only need a patch template, run with --print-patch-template") } mailboxID := resolveComposeMailboxID(runtime) if runtime.Bool("inspect") { return executeDraftInspect(runtime, mailboxID, draftID) } patch, err := buildDraftEditPatch(runtime) if err != nil { return err } rawDraft, err := draftpkg.GetRaw(runtime, mailboxID, draftID) if err != nil { return fmt.Errorf("read draft raw EML failed: %w", err) } snapshot, err := draftpkg.Parse(rawDraft) if err != nil { return output.ErrValidation("parse draft raw EML failed: %v", err) } if err := draftpkg.Apply(snapshot, patch); err != nil { return output.ErrValidation("apply draft patch failed: %v", err) } serialized, err := draftpkg.Serialize(snapshot) if err != nil { return output.ErrValidation("serialize draft failed: %v", err) } if err := draftpkg.UpdateWithRaw(runtime, mailboxID, draftID, serialized); err != nil { return fmt.Errorf("update draft failed: %w", err) } projection := draftpkg.Project(snapshot) out := map[string]interface{}{ "draft_id": draftID, "warning": "This edit flow has no optimistic locking. If the same draft is changed concurrently, the last writer wins.", "projection": projection, } runtime.OutFormat(out, nil, func(w io.Writer) { fmt.Fprintln(w, "Draft updated.") fmt.Fprintf(w, "draft_id: %s\n", draftID) if projection.Subject != "" { fmt.Fprintf(w, "subject: %s\n", sanitizeForTerminal(projection.Subject)) } if recipients := prettyDraftAddresses(projection.To); recipients != "" { fmt.Fprintf(w, "to: %s\n", sanitizeForTerminal(recipients)) } if projection.BodyText != "" { fmt.Fprintf(w, "body_text: %s\n", sanitizeForTerminal(projection.BodyText)) } if projection.BodyHTMLSummary != "" { fmt.Fprintf(w, "body_html_summary: %s\n", sanitizeForTerminal(projection.BodyHTMLSummary)) } if len(projection.AttachmentsSummary) > 0 { fmt.Fprintf(w, "attachments: %d\n", len(projection.AttachmentsSummary)) } if len(projection.InlineSummary) > 0 { fmt.Fprintf(w, "inline_parts: %d\n", len(projection.InlineSummary)) } if len(projection.Warnings) > 0 { fmt.Fprintf(w, "warnings: %s\n", sanitizeForTerminal(strings.Join(projection.Warnings, "; "))) } fmt.Fprintln(w, "warning: This edit flow has no optimistic locking. If the same draft is changed concurrently, the last writer wins.") }) return nil }, }
var MailForward = common.Shortcut{ Service: "mail", Command: "+forward", Description: "Forward a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Original message block included automatically.", 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"}, 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: "from", Desc: "Sender address; also selects the mailbox to send from (defaults to the authenticated user's primary mailbox)"}, {Name: "cc", Desc: "CC email address(es), comma-separated"}, {Name: "bcc", Desc: "BCC email address(es), comma-separated"}, {Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring all HTML auto-detection. Cannot be used with --inline."}, {Name: "attach", Desc: "Attachment file path(s), comma-separated, appended after original attachments (relative path only)"}, {Name: "inline", Desc: "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: "confirm-send", Type: "bool", Desc: "Send the forward immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { messageId := runtime.Str("message-id") to := runtime.Str("to") confirmSend := runtime.Bool("confirm-send") mailboxID := resolveComposeMailboxID(runtime) desc := "Forward: fetch original message → fetch mailbox profile (default From) → save as draft" if confirmSend { desc = "Forward (--confirm-send): fetch original message → fetch mailbox profile (default From) → create draft → send draft" } api := common.NewDryRunAPI(). Desc(desc). GET(mailboxPath(mailboxID, "messages", messageId)). GET(mailboxPath(mailboxID, "profile")). POST(mailboxPath(mailboxID, "drafts")). Body(map[string]interface{}{"raw": "<base64url-EML>", "_to": to}) if confirmSend { api = api.POST(mailboxPath(mailboxID, "drafts", "<draft_id>", "send")) } return api }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if err := validateConfirmSendScope(runtime); err != nil { return err } if runtime.Bool("confirm-send") { if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil { return err } } return validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "") }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { messageId := runtime.Str("message-id") to := runtime.Str("to") body := runtime.Str("body") fromFlag := runtime.Str("from") ccFlag := runtime.Str("cc") bccFlag := runtime.Str("bcc") plainText := runtime.Bool("plain-text") attachFlag := runtime.Str("attach") inlineFlag := runtime.Str("inline") confirmSend := runtime.Bool("confirm-send") mailboxID := resolveComposeMailboxID(runtime) sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId) if err != nil { return fmt.Errorf("failed to fetch original message: %w", err) } if err := validateForwardAttachmentURLs(sourceMsg); err != nil { return fmt.Errorf("forward blocked: %w", err) } orig := sourceMsg.Original senderEmail := fromFlag if senderEmail == "" { senderEmail = fetchCurrentUserEmail(runtime) if senderEmail == "" { senderEmail = orig.headTo } } if err := validateRecipientCount(to, ccFlag, bccFlag); err != nil { return err } bld := emlbuilder.New(). Subject(buildForwardSubject(orig.subject)). ToAddrs(parseNetAddrs(to)) if senderEmail != "" { bld = bld.From("", senderEmail) } if ccFlag != "" { bld = bld.CCAddrs(parseNetAddrs(ccFlag)) } if bccFlag != "" { bld = bld.BCCAddrs(parseNetAddrs(bccFlag)) } if inReplyTo := normalizeMessageID(orig.smtpMessageId); inReplyTo != "" { bld = bld.InReplyTo(inReplyTo) } if messageId != "" { bld = bld.LMSReplyToMessageID(messageId) } useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw)) if strings.TrimSpace(inlineFlag) != "" && !useHTML { return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML") } if useHTML { if err := validateInlineImageURLs(sourceMsg); err != nil { return fmt.Errorf("forward blocked: %w", err) } processedBody := buildBodyDiv(body, bodyIsHTML(body)) bld = bld.HTMLBody([]byte(processedBody + buildForwardQuoteHTML(&orig))) bld, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages) if err != nil { return err } } else { bld = bld.TextBody([]byte(buildForwardedMessage(&orig, body))) } // Download original attachments and accumulate size for limit check type downloadedAtt struct { content []byte contentType string filename string } var origAtts []downloadedAtt var origAttBytes int64 type largeAttID struct { ID string `json:"id"` } var largeAttIDs []largeAttID for _, att := range sourceMsg.ForwardAttachments { if att.AttachmentType == attachmentTypeLarge { largeAttIDs = append(largeAttIDs, largeAttID{ID: att.ID}) continue } content, err := downloadAttachmentContent(runtime, att.DownloadURL) if err != nil { return fmt.Errorf("failed to download original attachment %s: %w", att.Filename, err) } contentType := att.ContentType if contentType == "" { contentType = "application/octet-stream" } origAtts = append(origAtts, downloadedAtt{content, contentType, att.Filename}) origAttBytes += int64(len(content)) } if len(largeAttIDs) > 0 { idsJSON, err := json.Marshal(largeAttIDs) if err != nil { return fmt.Errorf("failed to encode large attachment IDs: %w", err) } bld = bld.Header("X-Lms-Large-Attachment-Ids", base64.StdEncoding.EncodeToString(idsJSON)) } inlineSpecs, err := parseInlineSpecs(inlineFlag) if err != nil { return err } if err := checkAttachmentSizeLimit(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), origAttBytes, len(origAtts)); err != nil { return err } for _, att := range origAtts { bld = bld.AddAttachment(att.content, att.contentType, att.filename) } for _, path := range splitByComma(attachFlag) { bld = bld.AddFileAttachment(path) } for _, spec := range inlineSpecs { bld = bld.AddFileInline(spec.FilePath, spec.CID) } rawEML, err := bld.BuildBase64URL() if err != nil { return fmt.Errorf("failed to build EML: %w", err) } draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML) if err != nil { return fmt.Errorf("failed to create draft: %w", err) } if !confirmSend { runtime.Out(map[string]interface{}{ "draft_id": draftID, "tip": fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftID), }, nil) hintSendDraft(runtime, mailboxID, draftID) return nil } resData, err := draftpkg.Send(runtime, mailboxID, draftID) if err != nil { return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftID, err) } runtime.Out(map[string]interface{}{ "message_id": resData["message_id"], "thread_id": resData["thread_id"], }, nil) hintMarkAsRead(runtime, mailboxID, messageId) return nil }, }
var MailMessage = common.Shortcut{ Service: "mail", Command: "+message", Description: "Use when reading full content for a single email by message ID. Returns normalized body content plus attachments metadata, including inline images.", Risk: "read", Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ {Name: "mailbox", Default: "me", Desc: "email address (default: me)"}, {Name: "message-id", Desc: "Required. Email message ID", Required: true}, {Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"}, {Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { mailboxID := resolveMailboxID(runtime) messageID := runtime.Str("message-id") return common.NewDryRunAPI(). Desc("Fetch full email content and attachments metadata, including inline images"). GET(mailboxPath(mailboxID, "messages", messageID)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { if runtime.Bool("print-output-schema") { printMessageOutputSchema(runtime) return nil } mailboxID := resolveMailboxID(runtime) hintIdentityFirst(runtime, mailboxID) messageID := runtime.Str("message-id") html := runtime.Bool("html") msg, err := fetchFullMessage(runtime, mailboxID, messageID, html) if err != nil { return fmt.Errorf("failed to fetch email: %w", err) } out := buildMessageOutput(msg, html) runtime.Out(out, nil) return nil }, }
var MailMessages = common.Shortcut{ Service: "mail", Command: "+messages", Description: "Use when reading full content for multiple emails by message ID. Prefer this shortcut over calling raw mail user_mailbox.messages batch_get directly, because it base64url-decodes body fields and returns normalized per-message output that is easier to consume.", Risk: "read", Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ {Name: "mailbox", Default: "me", Desc: "email address (default: me)"}, {Name: "message-ids", Desc: `Required. Comma-separated email message IDs. Example: "id1,id2,id3"`, Required: true}, {Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"}, {Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { mailboxID := resolveMailboxID(runtime) messageIDs := splitByComma(runtime.Str("message-ids")) body := map[string]interface{}{ "format": messageGetFormat(runtime.Bool("html")), "message_ids": []string{"<message_id_1>", "<message_id_2>"}, } if len(messageIDs) > 0 { body["message_ids"] = messageIDs } return common.NewDryRunAPI(). Desc("Fetch multiple emails via messages.batch_get (auto-chunked in batches of 20 IDs during execution)"). POST(mailboxPath(mailboxID, "messages", "batch_get")). Body(body) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { if runtime.Bool("print-output-schema") { printMessageOutputSchema(runtime) return nil } mailboxID := resolveMailboxID(runtime) hintIdentityFirst(runtime, mailboxID) messageIDs := splitByComma(runtime.Str("message-ids")) if len(messageIDs) == 0 { return output.ErrValidation("--message-ids is required; provide one or more message IDs separated by commas") } html := runtime.Bool("html") rawMessages, missingMessageIDs, err := fetchFullMessages(runtime, mailboxID, messageIDs, html) if err != nil { return err } messages := make([]map[string]interface{}, 0, len(rawMessages)) for _, msg := range rawMessages { messages = append(messages, buildMessageOutput(msg, html)) } runtime.Out(mailMessagesOutput{ Messages: messages, Total: len(messages), UnavailableMessageIDs: missingMessageIDs, }, nil) return nil }, }
var MailReply = common.Shortcut{ Service: "mail", Command: "+reply", Description: "Reply to a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Sets Re: subject, In-Reply-To, and References headers automatically.", 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"}, Flags: []common.Flag{ {Name: "message-id", Desc: "Required. Message ID to reply to", Required: true}, {Name: "body", Desc: "Required. 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: true}, {Name: "from", Desc: "Sender address; also selects the mailbox to send from (defaults to the authenticated user's primary mailbox)"}, {Name: "to", Desc: "Additional To address(es), comma-separated (appended to original sender's address)"}, {Name: "cc", Desc: "Additional CC email address(es), comma-separated"}, {Name: "bcc", Desc: "BCC email address(es), comma-separated"}, {Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring all HTML auto-detection. Cannot be used with --inline."}, {Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"}, {Name: "inline", Desc: "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: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { messageId := runtime.Str("message-id") confirmSend := runtime.Bool("confirm-send") mailboxID := resolveComposeMailboxID(runtime) desc := "Reply: fetch original message → fetch mailbox profile (default From) → save as draft" if confirmSend { desc = "Reply (--confirm-send): fetch original message → fetch mailbox profile (default From) → create draft → send draft" } api := common.NewDryRunAPI(). Desc(desc). GET(mailboxPath(mailboxID, "messages", messageId)). GET(mailboxPath(mailboxID, "profile")). POST(mailboxPath(mailboxID, "drafts")). Body(map[string]interface{}{"raw": "<base64url-EML>"}) if confirmSend { api = api.POST(mailboxPath(mailboxID, "drafts", "<draft_id>", "send")) } return api }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if err := validateConfirmSendScope(runtime); err != nil { return err } return validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "") }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { messageId := runtime.Str("message-id") body := runtime.Str("body") fromFlag := runtime.Str("from") toFlag := runtime.Str("to") ccFlag := runtime.Str("cc") bccFlag := runtime.Str("bcc") plainText := runtime.Bool("plain-text") attachFlag := runtime.Str("attach") inlineFlag := runtime.Str("inline") confirmSend := runtime.Bool("confirm-send") inlineSpecs, err := parseInlineSpecs(inlineFlag) if err != nil { return err } mailboxID := resolveComposeMailboxID(runtime) sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId) if err != nil { return fmt.Errorf("failed to fetch original message: %w", err) } orig := sourceMsg.Original senderEmail := fromFlag if senderEmail == "" { senderEmail = fetchCurrentUserEmail(runtime) if senderEmail == "" { senderEmail = orig.headTo } } replyTo := orig.replyTo if replyTo == "" { replyTo = orig.headFrom } replyTo = mergeAddrLists(replyTo, toFlag) useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw)) if strings.TrimSpace(inlineFlag) != "" && !useHTML { return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML") } var bodyStr string if useHTML { bodyStr = buildBodyDiv(body, bodyIsHTML(body)) } else { bodyStr = body } if err := validateRecipientCount(replyTo, ccFlag, bccFlag); err != nil { return err } quoted := quoteForReply(&orig, useHTML) bld := emlbuilder.New(). Subject(buildReplySubject(orig.subject)). ToAddrs(parseNetAddrs(replyTo)) if senderEmail != "" { bld = bld.From("", senderEmail) } if ccFlag != "" { bld = bld.CCAddrs(parseNetAddrs(ccFlag)) } if bccFlag != "" { bld = bld.BCCAddrs(parseNetAddrs(bccFlag)) } if inReplyTo := normalizeMessageID(orig.smtpMessageId); inReplyTo != "" { bld = bld.InReplyTo(inReplyTo) } if messageId != "" { bld = bld.LMSReplyToMessageID(messageId) } if useHTML { if err := validateInlineImageURLs(sourceMsg); err != nil { return fmt.Errorf("HTML reply blocked: %w", err) } bld = bld.HTMLBody([]byte(bodyStr + quoted)) bld, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages) if err != nil { return err } } else { bld = bld.TextBody([]byte(bodyStr + quoted)) } for _, path := range splitByComma(attachFlag) { bld = bld.AddFileAttachment(path) } for _, spec := range inlineSpecs { bld = bld.AddFileInline(spec.FilePath, spec.CID) } rawEML, err := bld.BuildBase64URL() if err != nil { return fmt.Errorf("failed to build EML: %w", err) } draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML) if err != nil { return fmt.Errorf("failed to create draft: %w", err) } if !confirmSend { runtime.Out(map[string]interface{}{ "draft_id": draftID, "tip": fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftID), }, nil) hintSendDraft(runtime, mailboxID, draftID) return nil } resData, err := draftpkg.Send(runtime, mailboxID, draftID) if err != nil { return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftID, err) } runtime.Out(map[string]interface{}{ "message_id": resData["message_id"], "thread_id": resData["thread_id"], }, nil) hintMarkAsRead(runtime, mailboxID, messageId) return nil }, }
var MailReplyAll = common.Shortcut{ Service: "mail", Command: "+reply-all", Description: "Reply to all recipients and save as draft (default). Use --confirm-send to send immediately after user confirmation. Includes all original To and CC automatically.", 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"}, Flags: []common.Flag{ {Name: "message-id", Desc: "Required. Message ID to reply to all recipients", Required: true}, {Name: "body", Desc: "Required. 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: true}, {Name: "from", Desc: "Sender address; also selects the mailbox to send from (defaults to the authenticated user's primary mailbox)"}, {Name: "to", Desc: "Additional To address(es), comma-separated (appended to original recipients)"}, {Name: "cc", Desc: "Additional CC email address(es), comma-separated"}, {Name: "bcc", Desc: "BCC email address(es), comma-separated"}, {Name: "remove", Desc: "Address(es) to exclude from the outgoing reply, comma-separated"}, {Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring all HTML auto-detection. Cannot be used with --inline."}, {Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"}, {Name: "inline", Desc: "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: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { messageId := runtime.Str("message-id") confirmSend := runtime.Bool("confirm-send") mailboxID := resolveComposeMailboxID(runtime) desc := "Reply-all: fetch original message (with recipients) → fetch mailbox profile (default From) → save as draft" if confirmSend { desc = "Reply-all (--confirm-send): fetch original message (with recipients) → fetch mailbox profile (default From) → create draft → send draft" } api := common.NewDryRunAPI(). Desc(desc). GET(mailboxPath(mailboxID, "messages", messageId)). GET(mailboxPath(mailboxID, "profile")). POST(mailboxPath(mailboxID, "drafts")). Body(map[string]interface{}{"raw": "<base64url-EML>"}) if confirmSend { api = api.POST(mailboxPath(mailboxID, "drafts", "<draft_id>", "send")) } return api }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if err := validateConfirmSendScope(runtime); err != nil { return err } return validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "") }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { messageId := runtime.Str("message-id") body := runtime.Str("body") fromFlag := runtime.Str("from") toFlag := runtime.Str("to") ccFlag := runtime.Str("cc") bccFlag := runtime.Str("bcc") removeFlag := runtime.Str("remove") plainText := runtime.Bool("plain-text") attachFlag := runtime.Str("attach") inlineFlag := runtime.Str("inline") confirmSend := runtime.Bool("confirm-send") inlineSpecs, err := parseInlineSpecs(inlineFlag) if err != nil { return err } mailboxID := resolveComposeMailboxID(runtime) sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId) if err != nil { return fmt.Errorf("failed to fetch original message: %w", err) } orig := sourceMsg.Original senderEmail := fromFlag if senderEmail == "" { senderEmail = fetchCurrentUserEmail(runtime) if senderEmail == "" { senderEmail = orig.headTo } } var removeList []string for _, r := range strings.Split(removeFlag, ",") { if s := strings.TrimSpace(r); s != "" { removeList = append(removeList, s) } } selfEmails := fetchSelfEmailSet(runtime, mailboxID) excluded := buildExcludeSet(selfEmails, removeList) replyToAddr := orig.replyTo if replyToAddr == "" { replyToAddr = orig.headFrom } isSelfSent := selfEmails[strings.ToLower(orig.headFrom)] || (senderEmail != "" && strings.EqualFold(orig.headFrom, senderEmail)) toList, ccList := buildReplyAllRecipients(replyToAddr, orig.toAddresses, orig.ccAddresses, senderEmail, excluded, isSelfSent) toList = mergeAddrLists(toList, toFlag) ccList = mergeAddrLists(ccList, ccFlag) if err := validateRecipientCount(toList, ccList, bccFlag); err != nil { return err } useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw)) if strings.TrimSpace(inlineFlag) != "" && !useHTML { return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML") } var bodyStr string if useHTML { bodyStr = buildBodyDiv(body, bodyIsHTML(body)) } else { bodyStr = body } quoted := quoteForReply(&orig, useHTML) bld := emlbuilder.New(). Subject(buildReplySubject(orig.subject)). ToAddrs(parseNetAddrs(toList)) if senderEmail != "" { bld = bld.From("", senderEmail) } if ccList != "" { bld = bld.CCAddrs(parseNetAddrs(ccList)) } if bccFlag != "" { bld = bld.BCCAddrs(parseNetAddrs(bccFlag)) } if inReplyTo := normalizeMessageID(orig.smtpMessageId); inReplyTo != "" { bld = bld.InReplyTo(inReplyTo) } if messageId != "" { bld = bld.LMSReplyToMessageID(messageId) } if useHTML { if err := validateInlineImageURLs(sourceMsg); err != nil { return fmt.Errorf("HTML reply-all blocked: %w", err) } bld = bld.HTMLBody([]byte(bodyStr + quoted)) bld, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages) if err != nil { return err } } else { bld = bld.TextBody([]byte(bodyStr + quoted)) } for _, path := range splitByComma(attachFlag) { bld = bld.AddFileAttachment(path) } for _, spec := range inlineSpecs { bld = bld.AddFileInline(spec.FilePath, spec.CID) } rawEML, err := bld.BuildBase64URL() if err != nil { return fmt.Errorf("failed to build EML: %w", err) } draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML) if err != nil { return fmt.Errorf("failed to create draft: %w", err) } if !confirmSend { runtime.Out(map[string]interface{}{ "draft_id": draftID, "tip": fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftID), }, nil) hintSendDraft(runtime, mailboxID, draftID) return nil } resData, err := draftpkg.Send(runtime, mailboxID, draftID) if err != nil { return fmt.Errorf("failed to send reply-all (draft %s created but not sent): %w", draftID, err) } runtime.Out(map[string]interface{}{ "message_id": resData["message_id"], "thread_id": resData["thread_id"], }, nil) hintMarkAsRead(runtime, mailboxID, messageId) return nil }, }
var MailSend = common.Shortcut{ Service: "mail", Command: "+send", Description: "Compose a new email and save as draft (default). Use --confirm-send to send immediately after user confirmation.", Risk: "write", Scopes: []string{"mail:user_mailbox.message:send", "mail:user_mailbox.message:modify", "mail:user_mailbox:readonly"}, AuthTypes: []string{"user"}, Flags: []common.Flag{ {Name: "to", Desc: "Recipient email address(es), comma-separated"}, {Name: "subject", Desc: "Required. Email subject", Required: true}, {Name: "body", Desc: "Required. 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: true}, {Name: "from", Desc: "Sender address; also selects the mailbox to send from (defaults to the authenticated user's primary mailbox)"}, {Name: "cc", Desc: "CC email address(es), comma-separated"}, {Name: "bcc", Desc: "BCC email address(es), comma-separated"}, {Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring HTML auto-detection. Cannot be used with --inline."}, {Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"}, {Name: "inline", Desc: "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: "confirm-send", Type: "bool", Desc: "Send the email immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { to := runtime.Str("to") subject := runtime.Str("subject") confirmSend := runtime.Bool("confirm-send") mailboxID := resolveComposeMailboxID(runtime) desc := "Compose email → save as draft" if confirmSend { desc = "Compose email → save as draft → send draft" } api := common.NewDryRunAPI(). Desc(desc). GET(mailboxPath(mailboxID, "profile")). POST(mailboxPath(mailboxID, "drafts")). Body(map[string]interface{}{ "raw": "<base64url-EML>", "_preview": map[string]interface{}{ "to": to, "subject": subject, }, }) if confirmSend { api = api.POST(mailboxPath(mailboxID, "drafts", "<draft_id>", "send")) } return api }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil { return err } return validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { to := runtime.Str("to") subject := runtime.Str("subject") body := runtime.Str("body") fromFlag := runtime.Str("from") ccFlag := runtime.Str("cc") bccFlag := runtime.Str("bcc") plainText := runtime.Bool("plain-text") attachFlag := runtime.Str("attach") inlineFlag := runtime.Str("inline") confirmSend := runtime.Bool("confirm-send") senderEmail := fromFlag if senderEmail == "" { senderEmail = fetchCurrentUserEmail(runtime) } bld := emlbuilder.New(). Subject(subject). ToAddrs(parseNetAddrs(to)) if senderEmail != "" { bld = bld.From("", senderEmail) } if ccFlag != "" { bld = bld.CCAddrs(parseNetAddrs(ccFlag)) } if bccFlag != "" { bld = bld.BCCAddrs(parseNetAddrs(bccFlag)) } if plainText { bld = bld.TextBody([]byte(body)) } else if bodyIsHTML(body) { bld = bld.HTMLBody([]byte(body)) } else { bld = bld.TextBody([]byte(body)) } inlineSpecs, err := parseInlineSpecs(inlineFlag) if err != nil { return err } for _, path := range splitByComma(attachFlag) { bld = bld.AddFileAttachment(path) } for _, spec := range inlineSpecs { bld = bld.AddFileInline(spec.FilePath, spec.CID) } rawEML, err := bld.BuildBase64URL() if err != nil { return fmt.Errorf("failed to build EML: %w", err) } mailboxID := resolveComposeMailboxID(runtime) draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML) if err != nil { return fmt.Errorf("failed to create draft: %w", err) } if !confirmSend { runtime.Out(map[string]interface{}{ "draft_id": draftID, "tip": fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftID), }, nil) hintSendDraft(runtime, mailboxID, draftID) return nil } resData, err := draftpkg.Send(runtime, mailboxID, draftID) if err != nil { return fmt.Errorf("failed to send email (draft %s created but not sent): %w", draftID, err) } runtime.Out(map[string]interface{}{ "message_id": resData["message_id"], "thread_id": resData["thread_id"], }, nil) return nil }, }
var MailThread = common.Shortcut{ Service: "mail", Command: "+thread", Description: "Use when querying a full mail conversation/thread by thread ID. Returns all messages in chronological order, including replies and drafts, with body content and attachments metadata, including inline images.", Risk: "read", Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ {Name: "mailbox", Default: "me", Desc: "email address (default: me)"}, {Name: "thread-id", Desc: "Required. Email thread ID", Required: true}, {Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"}, {Name: "include-spam-trash", Type: "bool", Desc: "Also return messages from SPAM and TRASH folders (excluded by default)"}, {Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { mailboxID := resolveMailboxID(runtime) threadID := runtime.Str("thread-id") params := map[string]interface{}{"format": messageGetFormat(runtime.Bool("html"))} if runtime.Bool("include-spam-trash") { params["include_spam_trash"] = true } return common.NewDryRunAPI(). Desc("Fetch all emails in thread with full body content"). GET(mailboxPath(mailboxID, "threads", threadID)). Params(params) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { if runtime.Bool("print-output-schema") { printMessageOutputSchema(runtime) return nil } mailboxID := resolveMailboxID(runtime) hintIdentityFirst(runtime, mailboxID) threadID := runtime.Str("thread-id") html := runtime.Bool("html") params := map[string]interface{}{"format": messageGetFormat(html)} if runtime.Bool("include-spam-trash") { params["include_spam_trash"] = true } listData, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "threads", threadID), params, nil) if err != nil { return fmt.Errorf("failed to get thread: %w", err) } // New API: data.thread.messages[]; fallback to old API: data.items[].message var items []interface{} if thread, ok := listData["thread"].(map[string]interface{}); ok { items, _ = thread["messages"].([]interface{}) } if len(items) == 0 { items, _ = listData["items"].([]interface{}) } if len(items) == 0 { runtime.Out(mailThreadOutput{ThreadID: threadID, MessageCount: 0, Messages: []map[string]interface{}{}}, nil) return nil } outs := make([]map[string]interface{}, 0, len(items)) for _, item := range items { envelope, ok := item.(map[string]interface{}) if !ok { continue } msg := envelope if inner, ok := envelope["message"].(map[string]interface{}); ok { msg = inner } outs = append(outs, buildMessageOutput(msg, html)) } messages := sortThreadMessagesByInternalDate(outs) runtime.Out(mailThreadOutput{ThreadID: threadID, MessageCount: len(messages), Messages: messages}, nil) return nil }, }
var MailTriage = common.Shortcut{ Service: "mail", Command: "+triage", Description: `List mail summaries (date/from/subject/message_id). Use --query for full-text search, --filter for exact-match conditions.`, Risk: "read", Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "format", Default: "table", Desc: "output format: table | json | data (both json/data output messages array only)"}, {Name: "max", Type: "int", Default: "20", Desc: "maximum number of messages to fetch (1-400; auto-paginates internally)"}, {Name: "filter", Desc: `exact-match condition filter (JSON). Narrow results by folder, label, sender, recipient, etc. Run --print-filter-schema to see all fields. Example: {"folder":"INBOX","from":["alice@example.com"]}`}, {Name: "mailbox", Default: "me", Desc: "email address (default: me)"}, {Name: "query", Desc: `full-text keyword search across from/to/subject/body (max 50 chars). Example: "budget report"`}, {Name: "labels", Type: "bool", Desc: "include label IDs in output"}, {Name: "print-filter-schema", Type: "bool", Desc: "print --filter field reference and exit"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { mailbox := resolveMailboxID(runtime) query := runtime.Str("query") showLabels := runtime.Bool("labels") maxCount := normalizeTriageMax(runtime.Int("max")) filter, err := parseTriageFilter(runtime.Str("filter")) d := common.NewDryRunAPI().Set("input_filter", runtime.Str("filter")) if err != nil { return d.Set("filter_error", err.Error()) } if usesTriageSearchPath(query, filter) { resolvedFilter, err := resolveSearchFilter(runtime, mailbox, filter, true) if err != nil { return d.Set("filter_error", err.Error()) } pageSize := maxCount if pageSize > searchPageMax { pageSize = searchPageMax } searchParams, searchBody, _ := buildSearchParams(runtime, mailbox, query, resolvedFilter, pageSize, "", true) d = d.POST(mailboxPath(mailbox, "search")). Params(searchParams). Body(searchBody). Desc("search messages (auto-paginates up to --max)") if showLabels { d = d.POST(mailboxPath(mailbox, "messages", "batch_get")). Body(map[string]interface{}{"format": "metadata", "message_ids": []string{"<message_id>"}}). Desc("batch_get messages with format=metadata to populate labels") } return d } resolvedFilter, err := resolveListFilter(runtime, mailbox, filter, true) if err != nil { return d.Set("filter_error", err.Error()) } pageSize := maxCount if pageSize > listPageMax { pageSize = listPageMax } listParams, _ := buildListParams(runtime, mailbox, resolvedFilter, pageSize, "", true) return d.GET(mailboxPath(mailbox, "messages")). Params(listParams). POST(mailboxPath(mailbox, "messages", "batch_get")). Body(map[string]interface{}{"format": "metadata", "message_ids": []string{"<message_id>"}}). Desc("list message IDs (auto-paginates up to --max); batch_get with format=metadata"). Set("resolve_note", "name→ID resolution for filter.folder/filter.label runs during execution; dry-run does not call folders/labels list APIs") }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { if runtime.Bool("print-filter-schema") { printTriageFilterSchema(runtime) return nil } mailbox := resolveMailboxID(runtime) hintIdentityFirst(runtime, mailbox) outFormat := runtime.Str("format") query := runtime.Str("query") if query != "" { if err := common.RejectDangerousChars("--query", query); err != nil { return err } } showLabels := runtime.Bool("labels") filter, err := parseTriageFilter(runtime.Str("filter")) if err != nil { return err } maxCount := normalizeTriageMax(runtime.Int("max")) var messages []map[string]interface{} if usesTriageSearchPath(query, filter) { resolvedFilter, err := resolveSearchFilter(runtime, mailbox, filter, false) if err != nil { return err } var pageToken string for len(messages) < maxCount { pageSize := maxCount - len(messages) if pageSize > searchPageMax { pageSize = searchPageMax } searchParams, searchBody, err := buildSearchParams(runtime, mailbox, query, resolvedFilter, pageSize, pageToken, false) if err != nil { return err } searchData, err := doJSONAPI(runtime, &larkcore.ApiReq{ HttpMethod: http.MethodPost, ApiPath: mailboxPath(mailbox, "search"), QueryParams: toQueryParams(searchParams), Body: searchBody, }, "API call failed") if err != nil { return err } pageMessages := buildTriageMessagesFromSearchItems(searchData["items"]) messages = append(messages, pageMessages...) pageHasMore, _ := searchData["has_more"].(bool) pageToken, _ = searchData["page_token"].(string) if !pageHasMore || pageToken == "" { break } } if len(messages) > maxCount { messages = messages[:maxCount] } if showLabels && len(messages) > 0 { messageIDs := make([]string, len(messages)) for i, m := range messages { messageIDs[i] = strVal(m["message_id"]) } enriched, err := fetchMessageMetas(runtime, mailbox, messageIDs) if err != nil { return err } mergeTriageLabels(messages, enriched) } } else { resolvedFilter, err := resolveListFilter(runtime, mailbox, filter, false) if err != nil { return err } var ( messageIDs []string pageToken string ) for len(messageIDs) < maxCount { pageSize := maxCount - len(messageIDs) if pageSize > listPageMax { pageSize = listPageMax } listParams, err := buildListParams(runtime, mailbox, resolvedFilter, pageSize, pageToken, false) if err != nil { return err } listData, err := doJSONAPI(runtime, &larkcore.ApiReq{ HttpMethod: http.MethodGet, ApiPath: mailboxPath(mailbox, "messages"), QueryParams: toQueryParams(listParams), }, "API call failed") if err != nil { return err } ids := extractTriageMessageIDs(listData["items"]) messageIDs = append(messageIDs, ids...) pageHasMore, _ := listData["has_more"].(bool) pageToken, _ = listData["page_token"].(string) if !pageHasMore || pageToken == "" { break } } if len(messageIDs) > maxCount { messageIDs = messageIDs[:maxCount] } messages, err = fetchMessageMetas(runtime, mailbox, messageIDs) if err != nil { return err } } switch outFormat { case "json", "data": output.PrintJson(runtime.IO().Out, messages) default: if len(messages) == 0 { fmt.Fprintln(runtime.IO().ErrOut, "No messages found.") return nil } var rows []map[string]interface{} for _, msg := range messages { row := map[string]interface{}{ "date": sanitizeForTerminal(strVal(msg["date"])), "from": sanitizeForTerminal(strVal(msg["from"])), "subject": sanitizeForTerminal(strVal(msg["subject"])), "message_id": msg["message_id"], } if showLabels { row["labels"] = msg["labels"] } rows = append(rows, row) } output.PrintTable(runtime.IO().Out, rows) fmt.Fprintf(runtime.IO().ErrOut, "\n%d message(s)\n", len(messages)) fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --message-id <id> to read full content") } return nil }, }
var MailWatch = common.Shortcut{ Service: "mail", Command: "+watch", Description: "Watch for incoming mail events via WebSocket (requires scope mail:event and bot event mail.user_mailbox.event.message_received_v1 added). Run with --print-output-schema to see per-format field reference before parsing output.", Risk: "read", Scopes: []string{"mail:event", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, AuthTypes: []string{"user"}, Flags: []common.Flag{ {Name: "format", Default: "data", Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"}, {Name: "msg-format", Default: "metadata", Desc: "message payload mode: metadata(headers + meta, for triage/notification) | minimal(IDs and state only, no headers, for tracking read/folder changes) | plain_text_full(all metadata fields + full plain-text body) | event(raw WebSocket event, no API call, for debug) | full(full message including HTML body and attachments)"}, {Name: "output-dir", Desc: "Write each message as a JSON file (always full payload, regardless of --msg-format)"}, {Name: "mailbox", Default: "me", Desc: "email address (default: me)"}, {Name: "labels", Desc: "filter: label names JSON array, e.g. [\"important\",\"team-label\"]"}, {Name: "folders", Desc: "filter: folder names JSON array, e.g. [\"inbox\",\"news\"]"}, {Name: "label-ids", Desc: "filter: label IDs JSON array, e.g. [\"FLAGGED\",\"IMPORTANT\"]"}, {Name: "folder-ids", Desc: "filter: folder IDs JSON array, e.g. [\"INBOX\",\"SENT\"]"}, {Name: "print-output-schema", Type: "bool", Desc: "Print output field reference per --msg-format (run this first to learn field names before parsing output)"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { mailbox := resolveMailboxID(runtime) msgFormat := runtime.Str("msg-format") labelIDsInput := runtime.Str("label-ids") folderIDsInput := runtime.Str("folder-ids") labelsInput := runtime.Str("labels") foldersInput := runtime.Str("folders") outputDir := runtime.Str("output-dir") resolvedFolderIDs, folderDeferred := resolveWatchFilterIDsForDryRun(folderIDsInput, foldersInput, false, resolveFolderSystemAliasOrID) resolvedLabelIDs, labelDeferred := resolveWatchFilterIDsForDryRun(labelIDsInput, labelsInput, false, resolveLabelSystemID) outputDirDisplay := "(stdout)" if outputDir != "" { outputDirDisplay = outputDir } effectiveFolderDisplay := strings.Join(resolvedFolderIDs, ",") if effectiveFolderDisplay == "" { effectiveFolderDisplay = "(none)" } effectiveLabelDisplay := strings.Join(resolvedLabelIDs, ",") if effectiveLabelDisplay == "" { effectiveLabelDisplay = "(none)" } dryRunDesc := "Step 1: subscribe mailbox events; Step 2: watch via WebSocket (long-running)" if folderDeferred || labelDeferred { dryRunDesc += "; non-system folder/label names are resolved to IDs during execution" } d := common.NewDryRunAPI(). Desc(dryRunDesc). Set("command", "mail +watch"). Set("app_id", runtime.Config.AppID). Set("msg_format", msgFormat). Set("output_dir", outputDirDisplay). Set("input_folder_ids", folderIDsInput). Set("input_folders", foldersInput). Set("input_label_ids", labelIDsInput). Set("input_labels", labelsInput). Set("effective_folder_ids", resolvedFolderIDs). Set("effective_label_ids", resolvedLabelIDs) d.POST(mailboxPath(mailbox, "event", "subscribe")). Desc(fmt.Sprintf("Subscribe mailbox events (effective_folder_ids=%s, effective_label_ids=%s)", effectiveFolderDisplay, effectiveLabelDisplay)). Body(map[string]interface{}{"event_type": 1}) if mailbox == "me" { d.GET(mailboxPath("me", "profile")). Desc("Resolve mailbox address for event filtering (requires scope mail:user_mailbox:readonly)") } if len(resolvedLabelIDs) > 0 { d.Set("filter_label_ids", strings.Join(resolvedLabelIDs, ",")) } if len(resolvedFolderIDs) > 0 { d.Set("filter_folder_ids", strings.Join(resolvedFolderIDs, ",")) } if msgFormat != "event" || len(resolvedLabelIDs) > 0 || len(resolvedFolderIDs) > 0 { params := map[string]interface{}{ "format": watchFetchFormat(msgFormat, len(resolvedLabelIDs) > 0 || len(resolvedFolderIDs) > 0), } d.GET(mailboxPath(mailbox, "messages", "{message_id}")). Params(params) } return d }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { if runtime.Bool("print-output-schema") { printWatchOutputSchema(runtime) return nil } mailbox := resolveMailboxID(runtime) hintIdentityFirst(runtime, mailbox) outFormat := runtime.Str("format") switch outFormat { case "json", "data", "": default: return output.ErrValidation("invalid --format %q: must be json or data", outFormat) } msgFormat := runtime.Str("msg-format") outputDir := runtime.Str("output-dir") if outputDir != "" { if outputDir == "~" || strings.HasPrefix(outputDir, "~/") { home, err := vfs.UserHomeDir() if err != nil { return fmt.Errorf("cannot expand ~: %w", err) } if outputDir == "~" { outputDir = home } else { outputDir = filepath.Join(home, outputDir[2:]) } } else if filepath.IsAbs(outputDir) { outputDir = filepath.Clean(outputDir) } else { safePath, err := validate.SafeOutputPath(outputDir) if err != nil { return err } outputDir = safePath } if err := vfs.MkdirAll(outputDir, 0700); err != nil { return fmt.Errorf("cannot create output directory %q: %w", outputDir, err) } resolved, err := filepath.EvalSymlinks(outputDir) if err != nil { return fmt.Errorf("cannot resolve output directory: %w", err) } outputDir = resolved } labelIDsInput := runtime.Str("label-ids") folderIDsInput := runtime.Str("folder-ids") labelsInput := runtime.Str("labels") foldersInput := runtime.Str("folders") errOut := runtime.IO().ErrOut out := runtime.IO().Out info := func(msg string) { fmt.Fprintln(errOut, msg) } resolvedLabelIDs, err := resolveWatchFilterIDs(runtime, mailbox, labelIDsInput, labelsInput, resolveLabelID, resolveLabelNames, resolveLabelSystemID, "label-ids", "labels", "label") if err != nil { return err } resolvedFolderIDs, err := resolveWatchFilterIDs(runtime, mailbox, folderIDsInput, foldersInput, resolveFolderID, resolveFolderNames, resolveFolderSystemAliasOrID, "folder-ids", "folders", "folder") if err != nil { return err } labelIDSet := make(map[string]bool, len(resolvedLabelIDs)) for _, id := range resolvedLabelIDs { if id != "" { labelIDSet[id] = true } } folderIDSet := make(map[string]bool, len(resolvedFolderIDs)) for _, id := range resolvedFolderIDs { if id != "" { folderIDSet[id] = true } } info(fmt.Sprintf("Subscribing mailbox events for: %s", mailbox)) _, err = runtime.CallAPI("POST", mailboxPath(mailbox, "event", "subscribe"), nil, map[string]interface{}{"event_type": 1}) if err != nil { return wrapWatchSubscribeError(err) } info("Mailbox subscribed.") var unsubOnce sync.Once var unsubErr error unsubscribe := func() error { unsubOnce.Do(func() { _, unsubErr = runtime.CallAPI("POST", mailboxPath(mailbox, "event", "unsubscribe"), nil, map[string]interface{}{"event_type": 1}) }) return unsubErr } mailboxFilter := mailbox if mailbox == "me" { resolved, profileErr := fetchMailboxPrimaryEmail(runtime, "me") if profileErr != nil { unsubscribe() return enhanceProfileError(profileErr) } mailboxFilter = resolved } eventCount := 0 handleEvent := func(data map[string]interface{}) { eventBody := extractMailEventBody(data) if mailboxFilter != "" { mailAddr, _ := eventBody["mail_address"].(string) if !strings.EqualFold(mailAddr, mailboxFilter) { return } } messageID, _ := eventBody["message_id"].(string) if messageID == "" { return } fetchMailbox := mailbox if eventAddr, _ := eventBody["mail_address"].(string); eventAddr != "" { fetchMailbox = eventAddr } needMessage := msgFormat != "event" || len(labelIDSet) > 0 || len(folderIDSet) > 0 || outputDir != "" var message map[string]interface{} if needMessage { var err error fetchFormat := watchFetchFormat(msgFormat, len(labelIDSet) > 0 || len(folderIDSet) > 0) if outputDir != "" { fetchFormat = "full" } message, err = fetchMessageForWatch(runtime, fetchMailbox, messageID, fetchFormat) if err != nil { output.PrintError(errOut, fmt.Sprintf("fetch message %s failed: %v", fetchFormat, err)) failureData := watchFetchFailureValue(messageID, fetchFormat, err, eventBody) if outputDir != "" { if _, writeErr := writeMailEventFile(outputDir, failureData, data); writeErr != nil { output.PrintError(errOut, fmt.Sprintf("failed to write event file: %v", writeErr)) } } output.PrintJson(out, failureData) return } } if len(folderIDSet) > 0 { folderID, _ := message["folder_id"].(string) if !folderIDSet[folderID] { return } } if len(labelIDSet) > 0 { if !messageHasLabel(message, labelIDSet) { return } } eventCount++ if message != nil { for _, field := range []string{"body_plain_text", "body_preview", "body_plain"} { if body, ok := message[field].(string); ok && body != "" { decoded := decodeBase64URL(body) if detectPromptInjection(decoded) { from, _ := message["from"].(string) fmt.Fprintf(errOut, "[SECURITY WARNING] Possible prompt injection detected in message from %s\n", sanitizeForTerminal(from)) } break } } } fullMessage := message var outputData interface{} = data if msgFormat != "event" && message != nil { if msgFormat == "minimal" { message = minimalWatchMessage(message) } outputData = map[string]interface{}{"message": message} } if outputDir != "" { _, err := writeMailEventFile(outputDir, decodeBodyFieldsForFile(fullMessage), data) if err != nil { output.PrintError(errOut, fmt.Sprintf("failed to write event file: %v", err)) } } switch outFormat { case "json", "": output.PrintNdjson(out, output.Envelope{OK: true, Identity: string(runtime.As()), Data: outputData}) case "data": output.PrintNdjson(out, outputData) } } rawHandler := func(ctx context.Context, event *larkevent.EventReq) error { var eventData map[string]interface{} if event.Body != nil { dec := json.NewDecoder(bytes.NewReader(event.Body)) dec.UseNumber() if err := dec.Decode(&eventData); err != nil { fmt.Fprintf(errOut, "warning: failed to decode event body: %v\n", err) } } if eventData == nil { eventData = make(map[string]interface{}) } handleEvent(eventData) return nil } sdkLogger := &mailWatchLogger{w: errOut} eventDispatcher := dispatcher.NewEventDispatcher("", "") eventDispatcher.InitConfig(larkevent.WithLogger(sdkLogger)) eventDispatcher.OnCustomizedEvent(mailEventType, rawHandler) endpoints := core.ResolveEndpoints(runtime.Config.Brand) domain := endpoints.Open info("Connecting to Feishu event WebSocket...") info(fmt.Sprintf("Listening for: %s", mailEventType)) info(fmt.Sprintf("Output mode: %s", msgFormat)) if mailboxFilter != "" { info(fmt.Sprintf("Filter: mailbox=%s", mailboxFilter)) } if len(folderIDSet) > 0 { info(fmt.Sprintf("Filter: folder-ids=%s", strings.Join(setKeys(folderIDSet), ","))) } if len(labelIDSet) > 0 { info(fmt.Sprintf("Filter: label-ids=%s", strings.Join(setKeys(labelIDSet), ","))) } cli := larkws.NewClient(runtime.Config.AppID, runtime.Config.AppSecret, larkws.WithEventHandler(eventDispatcher), larkws.WithDomain(domain), larkws.WithLogger(sdkLogger), ) sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { defer func() { if r := recover(); r != nil { fmt.Fprintf(errOut, "panic in signal handler: %v\n", r) } }() <-sigCh info(fmt.Sprintf("\nShutting down... (received %d events)", eventCount)) info("Unsubscribing mailbox events...") if unsubErr := unsubscribe(); unsubErr != nil { fmt.Fprintf(errOut, "Warning: unsubscribe failed: %v\n", unsubErr) } else { info("Mailbox unsubscribed.") } signal.Stop(sigCh) os.Exit(0) }() info("Connected. Waiting for mail events... (Ctrl+C to stop)") if err := cli.Start(ctx); err != nil { unsubscribe() return output.ErrNetwork("WebSocket connection failed: %v", err) } return nil }, }
Functions ¶
Types ¶
type InlineSpec ¶
InlineSpec represents one inline image entry from the --inline JSON array. CID must be a valid RFC 2822 content-id (e.g. a random hex string). FilePath is the local path to the image file.
type Mailbox ¶
Mailbox is a parsed RFC 2822 address: an optional display name plus an email address. The zero value represents a bare address with no name.
func ParseMailbox ¶
ParseMailbox parses a single address in any of the following forms:
alice@example.com Alice Smith <alice@example.com> "Alice Smith" <alice@example.com>
The function is intentionally total (never returns an error): syntactic validation of the email address is left to the Lark API. Control characters are stripped as a defense against header injection.
func ParseMailboxList ¶
ParseMailboxList splits a comma-separated address list and parses each entry. Entries with an empty email address are silently dropped.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package emlbuilder provides a Lark-API-compatible RFC 2822 EML message builder.
|
Package emlbuilder provides a Lark-API-compatible RFC 2822 EML message builder. |
|
Package filecheck provides mail attachment file validation utilities shared by the emlbuilder and draft packages.
|
Package filecheck provides mail attachment file validation utilities shared by the emlbuilder and draft packages. |