Files
larksuite-cli/shortcuts/mail/helpers.go
evandance 5e6a3eb857 feat(mail): return typed error envelopes across the mail domain (#1250)
* feat(mail): return typed error envelopes across the mail domain

Replace every produced error path in shortcuts/mail with typed errs.* envelopes, so consumers get stable category, subtype, param/params, hint, retryable, and log_id metadata for classification and recovery instead of free-form message text.

- Locally constructed mail errors move from output.Err* / output.Errorf / final fmt.Errorf / common legacy helpers to errs.* builders, with structured params on multi-flag validation and failed-precondition states kept non-retryable.

- API-call failures move from runtime.CallAPI / DoAPIJSON legacy boundaries to runtime.CallAPITyped or runtime.ClassifyAPIResponse, and mail-specific enrichers read errs.ProblemOf so typed code, subtype, hint, and log_id metadata are preserved.

- Batch draft-send partial failures now use runtime.OutPartialFailure so successful and failed draft sends stay in stdout while the command exits through a typed multi-status signal.

- Add mail-domain typed helpers, mail API code metadata, and guard wiring to keep shortcuts/mail from reintroducing legacy envelopes or legacy API calls.

- Keep genuine intermediate fmt.Errorf wraps in parser/builder layers annotated with nolint comments; command-facing paths wrap them into typed validation, API, network, or internal errors.

* fix(mail): report aborted draft-send batches as a single failure result

When an account-level failure interrupts a batch send after some drafts
already went out, the command previously produced two machine-readable
failure results: the partial-failure ledger on stdout and a second error
envelope on stderr. Consumers could not tell which one to recover from.

The batch ledger is now the only failure result for that case: it gains
aborted and abort_error fields carrying the typed cause, so callers can
see which drafts were sent, which failed, why the batch stopped, and how
to recover — all from stdout. A --stop-on-error stop keeps these fields
unset because stopping early there is the caller's own choice.
2026-06-04 21:02:20 +08:00

2712 lines
100 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
netmail "net/mail"
"net/url"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/validate"
"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/ics"
)
// hintIdentityFirst prints a one-line tip to stderr for read-only mail shortcuts
// that don't internally call user_mailboxes.profile. This helps models and users
// discover the identity-first workflow without needing skill documentation.
func hintIdentityFirst(runtime *common.RuntimeContext, mailboxID string) {
fmt.Fprintf(runtime.IO().ErrOut,
"tip: run \"lark-cli mail user_mailboxes profile --params '{\"user_mailbox_id\":\"%s\"}'\" to confirm your email identity\n",
sanitizeForTerminal(mailboxID))
}
// hintSendDraft prints a post-draft-save tip to stderr telling the user
// (or the calling agent) how to send the draft that was just created.
func hintSendDraft(runtime *common.RuntimeContext, mailboxID, draftID string) {
fmt.Fprintf(runtime.IO().ErrOut,
"tip: draft saved. To send this draft, run:\n"+
` lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`+"\n",
sanitizeForTerminal(mailboxID), sanitizeForTerminal(draftID))
}
// hintMarkAsRead prints a post-send tip to stderr suggesting the user mark the
// original message as read after a reply/reply-all/forward operation.
func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) {
fmt.Fprintf(runtime.IO().ErrOut,
"tip: mark original as read? lark-cli mail user_mailbox.messages batch_modify_message"+
` --params '{"user_mailbox_id":"%s"}' --data '{"message_ids":["%s"],"remove_label_ids":["UNREAD"]}'`+"\n",
sanitizeForTerminal(mailboxID), sanitizeForTerminal(originalMessageID))
}
// hintReadReceiptRequest prints a stderr tip when a message that the caller
// just read requested a read receipt (carries the READ_RECEIPT_REQUEST label).
// The tip is emitted at CLI level so any caller — agents that read SKILL.md
// and those that don't — sees the prompt. Privacy is sensitive here: sending
// a receipt tells the remote party "I have read your message", so the tip
// explicitly instructs the caller to ask the user before responding.
//
// All four interpolated values (fromEmail, subject, mailboxID, messageID)
// come from untrusted email content or raw API input; they are run through
// sanitizeForSingleLine (for fromEmail) / %q (for subject) / shellQuoteForHint
// (for the command-line values) so a crafted "From: x@y.com\ntip: reply
// harmless-looking-addr@attacker..." can't forge extra tip lines, and values
// with shell metacharacters survive copy-paste intact.
func hintReadReceiptRequest(runtime *common.RuntimeContext, mailboxID, messageID, fromEmail, subject string) {
fmt.Fprintf(runtime.IO().ErrOut,
"tip: sender requested a read receipt (READ_RECEIPT_REQUEST).\n"+
" - do NOT auto-act; ask the user first (from=%s, subject=%q)\n"+
" - if the user agrees to confirm they have read it:\n"+
" lark-cli mail +send-receipt --mailbox '%s' --message-id '%s' --yes\n"+
" - if the user wants to dismiss the banner without sending a receipt:\n"+
" lark-cli mail +decline-receipt --mailbox '%s' --message-id '%s'\n",
sanitizeForSingleLine(fromEmail), sanitizeForSingleLine(subject),
shellQuoteForHint(mailboxID), shellQuoteForHint(messageID),
shellQuoteForHint(mailboxID), shellQuoteForHint(messageID))
}
// shellQuoteForHint returns s sanitized for single-line terminal output AND
// safe to embed inside single-quoted shell arguments: each single quote in
// the payload is rewritten as '\” (close-quote, escaped quote, re-open
// quote). Callers are expected to wrap the result in outer single quotes,
// as hintReadReceiptRequest does in its format string. Use this only for
// user-copy-paste hints, not for building commands that the CLI itself
// executes.
func shellQuoteForHint(s string) string {
return strings.ReplaceAll(sanitizeForSingleLine(s), "'", `'\''`)
}
// requireSenderForRequestReceipt returns a validation error when --request-
// receipt is set but no sender address could be resolved. The Disposition-
// Notification-To header can only be addressed to a known sender — silently
// dropping the header when senderEmail is empty would mislead the caller into
// believing a receipt was requested when it wasn't. Intended to be called
// from a shortcut's Execute right after the sender address has been resolved.
//
// The error wording is deliberately generic about recovery: compose shortcuts
// (+send, +reply, +reply-all, +forward, +draft-create) can accept --from to
// set the sender, but +draft-edit's --from names the mailbox that owns the
// draft, not the DNT address — for that case the recovery is to make sure
// the draft already has a valid From header. Pointing at --from unconditionally
// would send +draft-edit users to the wrong flag.
func requireSenderForRequestReceipt(runtime *common.RuntimeContext, senderEmail string) error {
if !runtime.Bool("request-receipt") {
return nil
}
if strings.TrimSpace(senderEmail) == "" {
return mailValidationError(
"--request-receipt requires a resolvable sender address; specify a sender address where supported, or ensure the draft has a From address")
}
return nil
}
// validateHeaderAddress rejects addresses that cannot be safely embedded in
// a MIME header value: anything with a control character (CR / LF / DEL /
// other C0) or a dangerous Unicode code point (BiDi / zero-width / line
// separator) would let a malicious From header inject additional headers or
// visually spoof a recipient.
//
// This mirrors emlbuilder.validateHeaderValue and exists separately for
// call sites that build header patches directly (e.g. mail_draft_edit
// synthesizing a set_header op for Disposition-Notification-To) without
// going through the builder.
func validateHeaderAddress(addr string) error {
for _, r := range addr {
if r != '\t' && (r < 0x20 || r == 0x7f) {
return mailValidationError("address contains control character: %q", addr)
}
if common.IsDangerousUnicode(r) {
return mailValidationError("address contains dangerous Unicode code point: %q", addr)
}
}
return nil
}
// messageOutputSchema returns a JSON description of +message / +messages / +thread output fields.
// Used by --print-output-schema to let callers discover field names without reading skill docs.
func printMessageOutputSchema(runtime *common.RuntimeContext) {
schema := map[string]interface{}{
"_description": "Output field reference for mail +message / +messages / +thread",
"fields": map[string]string{
"message_id": "Email message ID",
"thread_id": "Thread ID",
"subject": "Email subject",
"head_from": "Sender object: {mail_address, name}",
"to": "To recipients: [{mail_address, name}]",
"cc": "CC recipients: [{mail_address, name}]",
"bcc": "BCC recipients: [{mail_address, name}]",
"date": "Time in EML (milliseconds)",
"date_formatted": "Human-readable send time, e.g. '2026-03-19 16:33'",
"smtp_message_id": "SMTP Message-ID conforming to RFC 2822",
"in_reply_to": "In-Reply-To email header",
"references": "References email header, list of ancestor SMTP message IDs",
"internal_date": "Create/receive/send time (milliseconds)",
"message_state": "Message state: 1 = received, 2 = sent, 3 = draft",
"message_state_text": "unknown / received / sent / draft",
"folder_id": "Folder ID. Values: INBOX, SENT, SPAM, ARCHIVED, STRANGER, or custom folder ID",
"label_ids": "List of label IDs",
"priority_type": "Priority value. Values: 0 = no priority, 1 = high, 3 = normal, 5 = low",
"priority_type_text": "unknown / high / normal / low",
"security_level": "Security/risk assessment object; present when the server has risk metadata",
"security_level.is_risk": "Boolean. true if the message is flagged as risky",
"security_level.risk_banner_level": "Risk severity. Values: WARNING (warning), DANGER (danger), INFO (informational)",
"security_level.risk_banner_reason": "Risk reason. Values: NO_REASON, IMPERSONATE_DOMAIN (similar-domain spoofing), IMPERSONATE_KP_NAME (key-person name spoofing), UNAUTH_EXTERNAL (unauthenticated external domain), MALICIOUS_URL, MALICIOUS_ATTACHMENT, PHISHING, IMPERSONATE_PARTNER (partner spoofing), EXTERNAL_ENCRYPTION_ATTACHMENT (external encrypted attachment)",
"security_level.is_header_from_external": "Boolean. true if the sender is from an external domain",
"security_level.via_domain": "SPF/DKIM domain shown when the email is sent on behalf of or forged, e.g. 'larksuite.com'",
"security_level.spam_banner_type": "Spam reason. Values: USER_REPORT (user reported spam), USER_BLOCK (sender blocked by user), ANTI_SPAM (system classified as spam), USER_RULE (matched inbox rule into spam), BLOCK_DOMIN (domain blocked by user), BLOCK_ADDRESS (address blocked by user)",
"security_level.spam_user_rule_id": "ID of the matched inbox rule",
"security_level.spam_banner_info": "Address or domain that matched the user's blocklist, e.g. 'larksuite.com'",
"draft_id": "Draft ID, obtainable via list drafts API",
"reply_to": "Reply-To email header",
"reply_to_smtp_message_id": "Reply-To SMTP Message-ID",
"body_plain_text": "Preferred body field for LLM reading; base64url-decoded and ANSI-sanitized",
"body_preview": "First 100 characters of plaintext body content, for quick preview of core email content",
"body_html": "Raw HTML body; omitted when --html=false",
"attachments": "Unified list of regular attachments and inline images",
"attachments[].id": "Attachment ID (use with download_url API)",
"attachments[].filename": "Attachment filename",
"attachments[].content_type": "MIME content type of the attachment",
"attachments[].attachment_type": "Attachment type. Values: 1 = normal, 2 = large attachment",
"attachments[].is_inline": "true = inline image, false = regular attachment",
"attachments[].cid": "Content-ID for inline images (maps to <img src='cid:...'>)",
"calendar_event": "Parsed calendar invitation; present when the email contains a text/calendar part",
"calendar_event.method": "iTIP method, e.g. REQUEST, CANCEL, REPLY",
"calendar_event.uid": "Globally unique event identifier (UID property)",
"calendar_event.summary": "Event title (SUMMARY property)",
"calendar_event.start": "Event start time in RFC 3339 / ISO 8601 format (UTC)",
"calendar_event.end": "Event end time in RFC 3339 / ISO 8601 format (UTC)",
"calendar_event.location": "Event location string; omitted when not set",
"calendar_event.organizer": "Organizer email address",
"calendar_event.attendees": "List of attendee email addresses",
},
"thread_extra_fields": map[string]string{
"thread_id": "Thread ID",
"message_count": "Number of messages in thread",
"messages": "Message array sorted by internal_date ascending (oldest first)",
},
"messages_extra_fields": map[string]string{
"total": "Number of successfully returned messages",
"unavailable_message_ids": "Requested IDs not returned by the API",
},
}
runtime.Out(schema, nil)
}
// printWatchOutputSchema prints the per-format field reference for +watch output.
// Used by --print-output-schema to let callers discover field names without reading skill docs.
func printWatchOutputSchema(runtime *common.RuntimeContext) {
schema := map[string]interface{}{
"minimal": map[string]interface{}{
"message": map[string]interface{}{
"message_id": "<message_id>",
"thread_id": "<thread_id>",
"folder_id": "INBOX",
"label_ids": []string{"UNREAD", "IMPORTANT"},
"internal_date": "1700000000000",
"message_state": 1,
},
},
"metadata": map[string]interface{}{
"message": map[string]interface{}{
"message_id": "<message_id>",
"thread_id": "<thread_id>",
"subject": "<subject>",
"head_from": map[string]string{"mail_address": "<address>", "name": "<name>"},
"to": []map[string]string{{"mail_address": "<address>", "name": "<name>"}},
"body_preview": "<preview>",
"internal_date": "1700000000000",
"folder_id": "INBOX",
"label_ids": []string{"UNREAD", "IMPORTANT"},
"message_state": 1,
"in_reply_to": "",
"references": "",
"reply_to": "",
"smtp_message_id": "<smtp_message_id>",
"security_level": map[string]bool{"is_risk": false},
"attachments": []interface{}{},
},
},
"plain_text_full": map[string]interface{}{
"message": map[string]interface{}{
"_note": "all fields from metadata, plus:",
"body_plain_text": "<plain text body>",
},
},
"full": map[string]interface{}{
"message": map[string]interface{}{
"_note": "all fields from plain_text_full, plus:",
"body_html": "<html body>",
"attachments": []map[string]interface{}{
{
"id": "<attachment_id>",
"filename": "<filename>",
"content_type": "<mime_type>",
"is_inline": false,
"cid": "",
"attachment_type": 1,
},
},
},
},
"event": map[string]interface{}{
"header": map[string]string{
"event_id": "<event_id>",
"create_time": "1700000000000",
},
"event": map[string]interface{}{
"mail_address": "<address>",
"message_id": "<message_id>",
"mailbox_type": 1,
},
},
}
b, _ := json.MarshalIndent(schema, "", " ")
fmt.Fprintln(runtime.IO().Out, string(b))
}
// resolveMailboxID returns the user_mailbox_id from --mailbox flag, defaulting to "me".
func resolveMailboxID(runtime *common.RuntimeContext) string {
id := runtime.Str("mailbox")
if id == "" {
return "me"
}
return id
}
// resolveComposeMailboxID returns the mailbox ID for compose shortcuts.
// Priority: --mailbox > --from > "me".
// When sending via an alias (send_as), use --mailbox for the owning mailbox
// and --from for the alias sender address.
func resolveComposeMailboxID(runtime *common.RuntimeContext) string {
if mb := runtime.Str("mailbox"); mb != "" {
return mb
}
if from := runtime.Str("from"); from != "" {
return from
}
return "me"
}
// mailboxPath builds the full open-api path for a user mailbox sub-resource.
// Each path segment is escaped independently to avoid reserved-char path breakage.
func mailboxPath(mailboxID string, segments ...string) string {
parts := make([]string, 0, len(segments)+1)
parts = append(parts, url.PathEscape(mailboxID))
for _, seg := range segments {
if seg == "" {
continue
}
parts = append(parts, url.PathEscape(seg))
}
return "/open-apis/mail/v1/user_mailboxes/" + strings.Join(parts, "/")
}
// fetchMailboxPrimaryEmail retrieves mailbox primary_email_address from
// user_mailboxes.profile. Returns the email address or an error.
func fetchMailboxPrimaryEmail(runtime *common.RuntimeContext, mailboxID string) (string, error) {
if mailboxID == "" {
mailboxID = "me"
}
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "profile"), nil, nil)
if err != nil {
return "", err
}
if email := extractPrimaryEmail(data); email != "" {
return email, nil
}
if nested, ok := data["data"].(map[string]interface{}); ok {
if email := extractPrimaryEmail(nested); email != "" {
return email, nil
}
}
return "", mailInvalidResponseError("profile API returned no primary_email_address")
}
// extractPrimaryEmail returns the user's primary email address from a
// mailbox profile API response (key "primary_email_address"), or "" when the
// field is missing or empty.
func extractPrimaryEmail(data map[string]interface{}) string {
if email, ok := data["primary_email_address"].(string); ok && strings.TrimSpace(email) != "" {
return strings.TrimSpace(email)
}
if mailbox, ok := data["user_mailbox"].(map[string]interface{}); ok {
if email, ok := mailbox["primary_email_address"].(string); ok && strings.TrimSpace(email) != "" {
return strings.TrimSpace(email)
}
}
return ""
}
// resolveComposeSenderEmail determines the sender email for compose shortcuts.
// Priority: --from > --mailbox > profile("me").
// The profile API only supports "me", so when --mailbox is set to a non-"me"
// address (e.g. a shared mailbox), its value is used directly as the sender.
func resolveComposeSenderEmail(runtime *common.RuntimeContext) string {
if from := runtime.Str("from"); from != "" {
return from
}
if mb := runtime.Str("mailbox"); mb != "" && mb != "me" {
return mb
}
email, _ := fetchMailboxPrimaryEmail(runtime, "me")
return email
}
// fetchSelfEmailSet returns a set of addresses to exclude as "self" in
// reply-all. It always tries profile("me"); when mailboxID or senderEmail
// differ from "me", those are added to the set as well so that shared-
// mailbox and alias addresses are also excluded.
func fetchSelfEmailSet(runtime *common.RuntimeContext, mailboxID string) map[string]bool {
set := make(map[string]bool)
// Always include the "me" primary email.
if email, _ := fetchMailboxPrimaryEmail(runtime, "me"); email != "" {
set[strings.ToLower(email)] = true
}
// Include mailboxID itself (covers shared mailbox addresses).
if mailboxID != "" && mailboxID != "me" {
set[strings.ToLower(mailboxID)] = true
}
// Include --from alias address so it's excluded from reply-all recipients.
if from := runtime.Str("from"); from != "" {
set[strings.ToLower(from)] = true
}
return set
}
// folderAliasToSystemID maps friendly folder alias to system folder ID.
var folderAliasToSystemID = map[string]string{
"inbox": "INBOX",
"sent": "SENT",
"draft": "DRAFT",
"trash": "TRASH",
"spam": "SPAM",
"archive": "ARCHIVED",
"archived": "ARCHIVED",
}
// folderSystemIDToAlias maps system folder IDs to the search API query names.
// Note: the search API uses "archive" (not "archived") for the ARCHIVED folder.
var folderSystemIDToAlias = map[string]string{
"INBOX": "inbox",
"SENT": "sent",
"DRAFT": "draft",
"TRASH": "trash",
"SPAM": "spam",
"ARCHIVED": "archive",
}
// searchOnlyFolderNames are folder names accepted only by the search API,
// not present in the folder list API. They are passed through as-is.
var searchOnlyFolderNames = map[string]bool{
"scheduled": true,
}
// folderSystemIDs are known built-in folder IDs that can be passed directly.
var folderSystemIDs = map[string]bool{
"INBOX": true,
"SENT": true,
"DRAFT": true,
"TRASH": true,
"SPAM": true,
"ARCHIVED": true,
}
// labelSystemIDs are known built-in label IDs that can be passed directly.
var labelSystemIDs = map[string]bool{
"FLAGGED": true,
"IMPORTANT": true,
"OTHER": true,
}
// systemLabelAliases maps all recognized user inputs (lowercase) to canonical system label IDs.
// These system labels can be passed via either --filter folder or --filter label.
// On search path they are sent as folder values; on list path they are sent as label_id.
var systemLabelAliases = map[string]string{
// IMPORTANT
"important": "IMPORTANT",
"priority": "IMPORTANT",
"重要邮件": "IMPORTANT",
// FLAGGED
"flagged": "FLAGGED",
"已加旗标": "FLAGGED",
// OTHER
"other": "OTHER",
"其他邮件": "OTHER",
}
// systemLabelSearchName maps system label IDs to the search API folder values.
// Note: the search API uses "priority" (not "important") for the IMPORTANT label.
var systemLabelSearchName = map[string]string{
"FLAGGED": "flagged",
"IMPORTANT": "priority",
"OTHER": "other",
}
// resolveSystemLabel checks if input is a system label alias (case-insensitive).
// Returns the canonical system label ID and true, or ("", false).
func resolveSystemLabel(input string) (string, bool) {
if id, ok := systemLabelAliases[strings.ToLower(strings.TrimSpace(input))]; ok {
return id, true
}
// Also check uppercase form directly (e.g. "FLAGGED", "IMPORTANT", "OTHER").
if id, ok := normalizeSystemID(input, labelSystemIDs); ok {
return id, true
}
return "", false
}
// folderInfo is the normalized local representation of a mailbox folder,
// used by the folder-resolution helpers.
type folderInfo struct {
ID string
Name string
ParentFolderID string
}
// labelInfo is the normalized local representation of a mailbox label,
// used by the label-resolution helpers.
type labelInfo struct {
ID string
Name string
}
// resolveFolderID accepts either a folder ID or a folder name and returns
// the canonical folder ID. System folder aliases (INBOX, SENT, etc.) are
// resolved locally without an API call; custom folders are looked up via
// the mailbox folders endpoint.
func resolveFolderID(runtime *common.RuntimeContext, mailboxID, input string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
return "", nil
}
if id, ok := normalizeSystemID(value, folderSystemIDs); ok {
return id, nil
}
folders, err := listMailboxFolders(runtime, mailboxID)
if err != nil {
return "", err
}
return resolveByName("folder", value, mailboxID, folders,
func(item folderInfo) string { return item.ID },
func(item folderInfo) string { return item.Name },
)
}
// resolveFolderName accepts either a folder ID or a folder name and returns
// the canonical folder ID.
func resolveFolderName(runtime *common.RuntimeContext, mailboxID, input string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
return "", nil
}
if id, ok := resolveFolderSystemAliasOrID(value); ok {
return id, nil
}
folders, err := listMailboxFolders(runtime, mailboxID)
if err != nil {
return "", err
}
return resolveByName("folder", value, mailboxID, folders,
func(item folderInfo) string { return item.ID },
func(item folderInfo) string { return item.Name },
)
}
// resolveLabelID accepts either a label ID or a label name and returns the
// canonical label ID. System label aliases (UNREAD, STARRED, etc.) resolve
// locally; custom labels are looked up via the mailbox labels endpoint.
func resolveLabelID(runtime *common.RuntimeContext, mailboxID, input string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
return "", nil
}
if id, ok := resolveLabelSystemID(value); ok {
return id, nil
}
labels, err := listMailboxLabels(runtime, mailboxID)
if err != nil {
return "", err
}
return resolveByName("label", value, mailboxID, labels,
func(item labelInfo) string { return item.ID },
func(item labelInfo) string { return item.Name },
)
}
// resolveLabelName accepts either a label ID or a label name and returns
// the canonical label ID (mirror of resolveFolderName for labels).
func resolveLabelName(runtime *common.RuntimeContext, mailboxID, input string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
return "", nil
}
if id, ok := resolveLabelSystemID(value); ok {
return id, nil
}
labels, err := listMailboxLabels(runtime, mailboxID)
if err != nil {
return "", err
}
id, err := resolveByName("label", value, mailboxID, labels,
func(item labelInfo) string { return item.ID },
func(item labelInfo) string { return item.Name },
)
if err != nil {
if matchID := matchLabelSuffixID(value, labels); matchID != "" {
return matchID, nil
}
return "", err
}
return id, nil
}
// resolveFolderQueryName resolves a folder ID or name to the API-side query
// value (search-style folder syntax). Used by +triage / search to translate
// user-facing folder identifiers into API-acceptable strings.
func resolveFolderQueryName(runtime *common.RuntimeContext, mailboxID, input string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
return "", nil
}
if searchOnlyFolderNames[strings.ToLower(value)] {
return strings.ToLower(value), nil
}
if id, ok := resolveFolderSystemAliasOrID(value); ok {
return folderSystemIDToAlias[id], nil
}
folders, err := listMailboxFolders(runtime, mailboxID)
if err != nil {
return "", err
}
name, err := resolveNameValueByNameAllowDuplicates("folder", value, mailboxID, folders,
func(item folderInfo) string { return item.ID },
func(item folderInfo) string { return item.Name },
)
if err != nil {
return "", err
}
return folderSearchPath(name, value, folders), nil
}
// resolveFolderQueryNameFromID resolves a folder ID (already known) to its
// API-side query value, skipping the by-name lookup path.
func resolveFolderQueryNameFromID(runtime *common.RuntimeContext, mailboxID, input string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
return "", nil
}
if id, ok := resolveFolderSystemAliasOrID(value); ok {
return folderSystemIDToAlias[id], nil
}
folders, err := listMailboxFolders(runtime, mailboxID)
if err != nil {
return "", err
}
name, err := resolveNameValueByID("folder", value, mailboxID, folders,
func(item folderInfo) string { return item.ID },
func(item folderInfo) string { return item.Name },
)
if err != nil {
return "", err
}
return folderSearchPath(name, value, folders), nil
}
// folderSearchPath returns the search API folder path for a resolved folder name.
// For subfolders, the search API requires "parent_name/child_name" format.
func folderSearchPath(resolvedName, input string, folders []folderInfo) string {
lower := strings.ToLower(strings.TrimSpace(input))
for _, f := range folders {
if strings.ToLower(f.Name) != lower && f.ID != input {
continue
}
if f.ParentFolderID == "" || f.ParentFolderID == "0" {
return resolvedName
}
for _, parent := range folders {
if parent.ID == f.ParentFolderID {
return parent.Name + "/" + resolvedName
}
}
return resolvedName
}
return resolvedName
}
// resolveLabelQueryName mirrors resolveFolderQueryName for labels: returns
// the search-style label query value from a label ID or name.
func resolveLabelQueryName(runtime *common.RuntimeContext, mailboxID, input string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
return "", nil
}
if id, ok := resolveLabelSystemID(value); ok {
return systemLabelSearchName[id], nil
}
labels, err := listMailboxLabels(runtime, mailboxID)
if err != nil {
return "", err
}
name, err := resolveNameValueByNameAllowDuplicates("label", value, mailboxID, labels,
func(item labelInfo) string { return item.ID },
func(item labelInfo) string { return item.Name },
)
if err != nil {
// Sub-label names contain the full path (e.g. "parent/child").
// If exact match fails, try suffix match for child label names.
if match := matchLabelSuffix(value, labels); match != "" {
return match, nil
}
return "", err
}
return name, nil
}
// resolveLabelQueryNameFromID mirrors resolveFolderQueryNameFromID for
// labels: shortcut path when the label ID is already known.
func resolveLabelQueryNameFromID(runtime *common.RuntimeContext, mailboxID, input string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
return "", nil
}
if id, ok := resolveLabelSystemID(value); ok {
return systemLabelSearchName[id], nil
}
labels, err := listMailboxLabels(runtime, mailboxID)
if err != nil {
return "", err
}
return resolveNameValueByID("label", value, mailboxID, labels,
func(item labelInfo) string { return item.ID },
func(item labelInfo) string { return item.Name },
)
}
// matchLabelSuffix finds a label whose name ends with "/input" (case-insensitive)
// and returns the full label name. Used for search path resolution.
func matchLabelSuffix(input string, labels []labelInfo) string {
lower := strings.ToLower(input)
suffix := "/" + lower
for _, l := range labels {
name := strings.TrimSpace(l.Name)
if strings.HasSuffix(strings.ToLower(name), suffix) {
return name
}
}
return ""
}
// matchLabelSuffixID finds a label whose name ends with "/input" (case-insensitive)
// and returns the label ID. Used for list path resolution.
func matchLabelSuffixID(input string, labels []labelInfo) string {
lower := strings.ToLower(input)
suffix := "/" + lower
for _, l := range labels {
name := strings.TrimSpace(l.Name)
if strings.HasSuffix(strings.ToLower(name), suffix) {
return l.ID
}
}
return ""
}
// resolveFolderNames resolves a list of folder IDs / names to their
// human-readable names. Stops at the first error; partial results are not
// returned.
func resolveFolderNames(runtime *common.RuntimeContext, mailboxID string, values []string) ([]string, error) {
resolved := make([]string, 0, len(values))
seen := make(map[string]bool)
names := make([]string, 0, len(values))
for _, raw := range values {
value := strings.TrimSpace(raw)
if value == "" {
continue
}
if id, ok := resolveFolderSystemAliasOrID(value); ok {
addUniqueID(&resolved, seen, id)
continue
}
names = append(names, value)
}
if len(names) == 0 {
return resolved, nil
}
folders, err := listMailboxFolders(runtime, mailboxID)
if err != nil {
return nil, err
}
for _, value := range names {
id, err := resolveByName("folder", value, mailboxID, folders,
func(item folderInfo) string { return item.ID },
func(item folderInfo) string { return item.Name },
)
if err != nil {
return nil, err
}
addUniqueID(&resolved, seen, id)
}
return resolved, nil
}
// resolveLabelNames is the label-side counterpart of resolveFolderNames.
func resolveLabelNames(runtime *common.RuntimeContext, mailboxID string, values []string) ([]string, error) {
resolved := make([]string, 0, len(values))
seen := make(map[string]bool)
names := make([]string, 0, len(values))
for _, raw := range values {
value := strings.TrimSpace(raw)
if value == "" {
continue
}
if id, ok := resolveLabelSystemID(value); ok {
addUniqueID(&resolved, seen, id)
continue
}
names = append(names, value)
}
if len(names) == 0 {
return resolved, nil
}
labels, err := listMailboxLabels(runtime, mailboxID)
if err != nil {
return nil, err
}
for _, value := range names {
id, err := resolveByName("label", value, mailboxID, labels,
func(item labelInfo) string { return item.ID },
func(item labelInfo) string { return item.Name },
)
if err != nil {
return nil, err
}
addUniqueID(&resolved, seen, id)
}
return resolved, nil
}
// resolveFolderSystemAliasOrID returns the canonical system folder ID for
// the given input (an alias like "INBOX" or an ID). Returns (id, true) when
// recognised; ("", false) for non-system inputs.
func resolveFolderSystemAliasOrID(input string) (string, bool) {
if id, ok := folderAliasToSystemID[strings.ToLower(strings.TrimSpace(input))]; ok {
return id, true
}
return normalizeSystemID(input, folderSystemIDs)
}
// resolveLabelSystemID is the label counterpart of
// resolveFolderSystemAliasOrID: returns the system label ID when input
// matches a known system label.
func resolveLabelSystemID(input string) (string, bool) {
return resolveSystemLabel(input)
}
// normalizeSystemID checks whether input is a known system identifier
// listed in systemIDs and returns the canonical form. Returns ("", false)
// when input does not match any system ID.
func normalizeSystemID(input string, systemIDs map[string]bool) (string, bool) {
canonical := strings.ToUpper(strings.TrimSpace(input))
if canonical == "" {
return "", false
}
if systemIDs[canonical] {
return canonical, true
}
return "", false
}
// addUniqueID appends id to *dst when id is non-empty and not already in
// the seen set. Both dst and seen are updated in place.
func addUniqueID(dst *[]string, seen map[string]bool, id string) {
if id == "" || seen[id] {
return
}
seen[id] = true
*dst = append(*dst, id)
}
// listMailboxFolders fetches every custom folder for a mailbox via the
// folders.list API. System folders are NOT included; callers that need them
// should fall back to local resolution via resolveFolderSystemAliasOrID.
func listMailboxFolders(runtime *common.RuntimeContext, mailboxID string) ([]folderInfo, error) {
if err := validateFolderReadScope(runtime); err != nil {
return nil, err
}
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "folders"), nil, nil)
if err != nil {
return nil, mailAppendProblemHint(
mailDecorateProblemMessage(err, "unable to resolve --folder: failed to list folders"),
resolveLookupHint("folder", mailboxID))
}
items, _ := data["items"].([]interface{})
folders := make([]folderInfo, 0, len(items))
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
id := strVal(m["id"])
if id == "" {
continue
}
folders = append(folders, folderInfo{ID: id, Name: strVal(m["name"]), ParentFolderID: strVal(m["parent_folder_id"])})
}
return folders, nil
}
// listMailboxLabels is the label counterpart of listMailboxFolders.
func listMailboxLabels(runtime *common.RuntimeContext, mailboxID string) ([]labelInfo, error) {
if err := validateLabelReadScope(runtime); err != nil {
return nil, err
}
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "labels"), nil, nil)
if err != nil {
return nil, mailAppendProblemHint(
mailDecorateProblemMessage(err, "unable to resolve --label: failed to list labels"),
resolveLookupHint("label", mailboxID))
}
items, _ := data["items"].([]interface{})
labels := make([]labelInfo, 0, len(items))
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
id := strVal(m["id"])
if id == "" {
continue
}
labels = append(labels, labelInfo{ID: id, Name: strVal(m["name"])})
}
return labels, nil
}
// resolveByName looks up input as an exact ID first, then as a name, and
// returns the matching ID. Errors out on duplicate names so callers get a clear
// "ambiguous name" signal rather than silently picking one match.
func resolveByName[T any](kind, input, mailboxID string, items []T, idFn func(T) string, nameFn func(T) string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
return "", nil
}
for _, item := range items {
if id := idFn(item); id != "" && id == value {
return id, nil
}
}
lower := strings.ToLower(value)
matches := make([]string, 0, 2)
matchSet := make(map[string]bool)
for _, item := range items {
name := strings.TrimSpace(nameFn(item))
if name == "" || strings.ToLower(name) != lower {
continue
}
id := idFn(item)
if id == "" || matchSet[id] {
continue
}
matchSet[id] = true
matches = append(matches, id)
}
if len(matches) == 1 {
return matches[0], nil
}
if len(matches) > 1 {
return "", mailValidationError("%s name %q matches multiple IDs (%s); please use an ID", kind, value, strings.Join(matches, ","))
}
return "", mailValidationError("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
}
// resolveNameValueByID is the inverse of resolveByID: it looks up an ID
// and returns the matching name, used by the *QueryName resolvers.
func resolveNameValueByID[T any](kind, input, mailboxID string, items []T, idFn func(T) string, nameFn func(T) string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
return "", nil
}
for _, item := range items {
if id := idFn(item); id != "" && id == value {
name := strings.TrimSpace(nameFn(item))
if name == "" {
return "", mailValidationError("%s %q has empty name; cannot use it with query filters", kind, value)
}
return name, nil
}
}
return "", mailValidationError("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
}
// resolveNameValueByNameAllowDuplicates looks up input as an exact ID first,
// then as a name, and returns the matching name. Duplicate names are tolerated
// by returning the first match. Used in query-style contexts where ambiguity is
// acceptable because the API itself disambiguates server-side.
func resolveNameValueByNameAllowDuplicates[T any](kind, input, mailboxID string, items []T, idFn func(T) string, nameFn func(T) string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
return "", nil
}
for _, item := range items {
if id := idFn(item); id != "" && id == value {
name := strings.TrimSpace(nameFn(item))
if name == "" {
return "", mailValidationError("%s %q has empty name; cannot use it with query filters", kind, value)
}
return name, nil
}
}
lower := strings.ToLower(value)
for _, item := range items {
name := strings.TrimSpace(nameFn(item))
if name == "" || strings.ToLower(name) != lower {
continue
}
return name, nil
}
return "", mailValidationError("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID))
}
// resolveLookupHint returns the CLI command a user should run to list
// valid IDs / names for the given lookup kind ("folder" / "label") and
// mailbox. Used in not-found error messages so callers see an immediate
// recovery path.
func resolveLookupHint(kind, mailboxID string) string {
if mailboxID == "" {
mailboxID = "me"
}
switch kind {
case "folder":
return fmt.Sprintf("Run `lark-cli mail user_mailbox.folders list --params '{\"user_mailbox_id\":\"%s\"}'` to inspect available folder IDs and names.", mailboxID)
case "label":
return fmt.Sprintf("Run `lark-cli api GET '/open-apis/mail/v1/user_mailboxes/%s/labels' --as user` to inspect available label IDs and names.", validate.EncodePathSegment(mailboxID))
default:
return ""
}
}
// fetchFullMessage calls message.get.
// html=true -> format=full
// html=false -> format=plain_text_full (server omits body_html)
func fetchFullMessage(runtime *common.RuntimeContext, mailboxID, messageID string, html bool) (map[string]interface{}, error) {
params := map[string]interface{}{"format": messageGetFormat(html)}
data, err := runtime.CallAPITyped("GET", mailboxPath(mailboxID, "messages", messageID), params, nil)
if err != nil {
return nil, err
}
msg, _ := data["message"].(map[string]interface{})
if msg == nil {
return nil, mailInvalidResponseError("API response missing message field")
}
return msg, nil
}
// fetchFullMessages calls messages.batch_get and preserves the requested ID order.
// It returns the fetched raw message objects plus any IDs not returned by the API.
func fetchFullMessages(runtime *common.RuntimeContext, mailboxID string, messageIDs []string, html bool) ([]map[string]interface{}, []string, error) {
if len(messageIDs) == 0 {
return nil, nil, nil
}
const maxBatchGetMessageIDs = 20
byID := make(map[string]map[string]interface{}, len(messageIDs))
for start := 0; start < len(messageIDs); start += maxBatchGetMessageIDs {
end := start + maxBatchGetMessageIDs
if end > len(messageIDs) {
end = len(messageIDs)
}
data, err := runtime.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_get"), nil, map[string]interface{}{
"format": messageGetFormat(html),
"message_ids": messageIDs[start:end],
})
if err != nil {
return nil, nil, err
}
rawMessages, _ := data["messages"].([]interface{})
for _, item := range rawMessages {
msg, ok := item.(map[string]interface{})
if !ok {
continue
}
messageID := strVal(msg["message_id"])
if messageID == "" {
continue
}
byID[messageID] = msg
}
}
ordered := make([]map[string]interface{}, 0, len(messageIDs))
missing := make([]string, 0)
for _, messageID := range messageIDs {
if msg, ok := byID[messageID]; ok {
ordered = append(ordered, msg)
continue
}
missing = append(missing, messageID)
}
return ordered, missing, nil
}
// messageGetFormat maps an html flag to the server-side messages.get format
// value: "full" when HTML body is wanted, "plain_text_full" otherwise (the
// server then omits body_html, saving bandwidth).
func messageGetFormat(html bool) string {
if html {
return "full"
}
return "plain_text_full"
}
// extractAttachmentIDs returns the attachment IDs from a raw message map.
func extractAttachmentIDs(msg map[string]interface{}) []string {
rawAtts, _ := msg["attachments"].([]interface{})
ids := make([]string, 0, len(rawAtts))
for _, item := range rawAtts {
if att, ok := item.(map[string]interface{}); ok {
if id := strVal(att["id"]); id != "" {
ids = append(ids, id)
}
}
}
return ids
}
// warningEntry is a single structured warning emitted alongside primary
// output (e.g. when an attachment fails to download but the message itself
// is still returned). Serialized via the shared "warnings" output channel.
type warningEntry struct {
Code string `json:"code"`
Level string `json:"level"`
MessageID string `json:"message_id"`
AttachmentID string `json:"attachment_id"`
Retryable bool `json:"retryable"`
Detail string `json:"detail"`
}
// mailAddressOutput is the JSON-serialized address form used in public
// output (name + email). Distinct from mailAddressPair which is the
// internal value type used during body composition.
type mailAddressOutput struct {
Email string `json:"email"`
Name string `json:"name"`
}
// mailAddressPair is a name+email pair used for display in HTML and plaintext quote blocks.
type mailAddressPair struct {
Email string
Name string
}
// toAddressPairList converts JSON-output addresses (mailAddressOutput) to
// the internal mailAddressPair type used during body composition,
// dropping entries without an email address.
func toAddressPairList(raw []mailAddressOutput) []mailAddressPair {
out := make([]mailAddressPair, 0, len(raw))
for _, addr := range raw {
if addr.Email != "" {
out = append(out, mailAddressPair{Email: addr.Email, Name: addr.Name})
}
}
return out
}
// mailAttachmentOutput is the JSON form of a regular (non-inline)
// attachment: ID, filename, content type, attachment type code, and the
// time-limited download URL when requested.
type mailAttachmentOutput struct {
ID string `json:"id"`
Filename string `json:"filename"`
ContentType string `json:"content_type,omitempty"`
AttachmentType int `json:"attachment_type"`
DownloadURL string `json:"download_url,omitempty"`
}
// mailImageOutput is the JSON form of a CID-referenced inline image in the
// HTML body. CID is required; DownloadURL is optional.
type mailImageOutput struct {
ID string `json:"id"`
Filename string `json:"filename"`
ContentType string `json:"content_type,omitempty"`
CID string `json:"cid"`
DownloadURL string `json:"download_url,omitempty"`
}
// mailPublicAttachmentOutput is the unified attachment shape exposed on the
// public "attachments" field of message output — merges inline and regular
// attachments with an IsInline flag and optional CID.
type mailPublicAttachmentOutput struct {
ID string `json:"id"`
Filename string `json:"filename"`
ContentType string `json:"content_type,omitempty"`
AttachmentType int `json:"attachment_type,omitempty"`
IsInline bool `json:"is_inline"`
CID string `json:"cid,omitempty"`
}
// mailSecurityLevelOutput is the JSON form of the message's risk banner
// classification (external / phishing / similar). Present only when the
// backend flags the message; omitted on trusted messages.
type mailSecurityLevelOutput struct {
IsRisk bool `json:"is_risk"`
RiskBannerLevel string `json:"risk_banner_level"`
RiskBannerReason string `json:"risk_banner_reason"`
IsHeaderFromExternal bool `json:"is_header_from_external"`
ViaDomain string `json:"via_domain"`
SpamBannerType string `json:"spam_banner_type"`
SpamUserRuleID string `json:"spam_user_rule_id"`
SpamBannerInfo string `json:"spam_banner_info"`
}
// normalizedMessageForCompose is an internal-only shape used by reply/forward flows.
// It is not the public JSON contract of `mail +message` / `mail +thread`.
type normalizedMessageForCompose struct {
MessageID string `json:"message_id"`
ThreadID string `json:"thread_id"`
SMTPMessageID string `json:"smtp_message_id"`
Subject string `json:"subject"`
From mailAddressOutput `json:"from"`
To []mailAddressOutput `json:"to"`
CC []mailAddressOutput `json:"cc"`
BCC []mailAddressOutput `json:"bcc"`
Date string `json:"date"`
InReplyTo string `json:"in_reply_to"`
ReplyTo string `json:"reply_to,omitempty"`
ReplyToSMTPMessageID string `json:"reply_to_smtp_message_id,omitempty"`
References []string `json:"references"`
InternalDate string `json:"internal_date"`
DateFormatted string `json:"date_formatted"`
MessageState int `json:"message_state"`
MessageStateText string `json:"message_state_text"`
FolderID string `json:"folder_id"`
LabelIDs []string `json:"label_ids"`
PriorityType string `json:"priority_type,omitempty"`
PriorityTypeText string `json:"priority_type_text,omitempty"`
SecurityLevel *mailSecurityLevelOutput `json:"security_level,omitempty"`
BodyPlainText string `json:"body_plain_text"`
BodyPreview string `json:"body_preview"`
BodyHTML string `json:"body_html,omitempty"`
CalendarEvent *calendarEventOutput `json:"calendar_event,omitempty"`
Attachments []mailAttachmentOutput `json:"attachments"`
Images []mailImageOutput `json:"images"`
Warnings []warningEntry `json:"warnings,omitempty"`
}
type calendarEventOutput struct {
Method string `json:"method,omitempty"`
UID string `json:"uid,omitempty"`
Summary string `json:"summary,omitempty"`
Start string `json:"start,omitempty"`
End string `json:"end,omitempty"`
Location string `json:"location,omitempty"`
Organizer string `json:"organizer,omitempty"`
Attendees []string `json:"attendees,omitempty"`
}
// fetchAttachmentURLs fetches download URLs for the given attachment IDs in batches of 20.
// List params are embedded directly in the URL (SDK workaround for repeated query params).
// It never returns an error: failed batches/IDs are converted to structured warnings so caller can continue.
func fetchAttachmentURLs(runtime *common.RuntimeContext, mailboxID, messageID string, ids []string) (map[string]string, []warningEntry) {
callAPI := func(url string) (map[string]interface{}, error) {
return runtime.CallAPITyped("GET", url, nil, nil)
}
emitWarning := func(w warningEntry) {
fmt.Fprintf(runtime.IO().ErrOut, "warning: code=%s message_id=%s attachment_id=%s retryable=%t detail=%s\n", w.Code, w.MessageID, w.AttachmentID, w.Retryable, w.Detail)
}
return fetchAttachmentURLsWith(runtime, mailboxID, messageID, ids, callAPI, emitWarning)
}
// fetchAttachmentURLsWith resolves time-limited download URLs for each
// attachment ID via the attachments.download_url API. Returns a per-ID URL
// map plus a list of warnings for IDs the backend declined to resolve.
func fetchAttachmentURLsWith(
runtime *common.RuntimeContext,
mailboxID, messageID string,
ids []string,
callAPI func(url string) (map[string]interface{}, error),
emitWarning func(w warningEntry),
) (map[string]string, []warningEntry) {
if len(ids) == 0 {
return nil, nil
}
urlMap := make(map[string]string, len(ids))
warnings := make([]warningEntry, 0)
const batchSize = 20
for i := 0; i < len(ids); i += batchSize {
end := i + batchSize
if end > len(ids) {
end = len(ids)
}
batch := ids[i:end]
parts := make([]string, len(batch))
for j, id := range batch {
parts[j] = "attachment_ids=" + url.QueryEscape(id)
}
apiURL := mailboxPath(mailboxID, "messages", messageID, "attachments", "download_url") +
"?" + strings.Join(parts, "&")
data, err := callAPI(apiURL)
if err != nil {
warn := warningEntry{
Code: "attachment_download_url_api_error",
Level: "warning",
MessageID: messageID,
AttachmentID: "",
Retryable: true,
Detail: err.Error(),
}
warnings = append(warnings, warn)
emitWarning(warn)
continue
}
if urls, ok := data["download_urls"].([]interface{}); ok {
for _, item := range urls {
if m, ok := item.(map[string]interface{}); ok {
attID := strVal(m["attachment_id"])
dlURL := strVal(m["download_url"])
if attID != "" {
urlMap[attID] = dlURL
}
}
}
}
if failed, ok := data["failed_ids"].([]interface{}); ok {
for _, f := range failed {
if id, ok := f.(string); ok && id != "" {
warn := warningEntry{
Code: "attachment_download_url_failed_id",
Level: "warning",
MessageID: messageID,
AttachmentID: id,
Retryable: false,
Detail: "attachment id returned in failed_ids",
}
warnings = append(warnings, warn)
emitWarning(warn)
}
}
}
}
return urlMap, warnings
}
// rawMessageExcludedFields lists API response fields that must NOT be
// auto-passed through to the public output because they are replaced by a
// derived public shape (see buildPublicAttachments / derivedMessageFields).
var rawMessageExcludedFields = map[string]struct{}{
"attachments": {},
}
// derivedMessageFields names the public output keys that are synthesized
// from the raw API response rather than copied through verbatim. Used by
// shouldExposeRawMessageField and by the output schema printed for agents.
var derivedMessageFields = []string{
"draft_id",
"body_plain_text",
"body_preview",
"body_html",
"attachments",
"date_formatted",
"message_state_text",
"priority_type_text",
}
// buildMessageOutput assembles the public shortcut output from a raw message map and attachment URL map.
//
// Output model:
// - raw passthrough: safe message metadata fields that do not need special processing
// - derived fields: decoded body, attachment list, and helper text fields
//
// Raw passthrough excludes:
// - all `body_*` fields
// - `attachments`
//
// Derived fields are listed in `derivedMessageFields`.
func buildMessageOutput(msg map[string]interface{}, html bool) map[string]interface{} {
out := pickSafeMessageFields(msg)
normalized := buildMessageForCompose(msg, nil, html)
if draftID := derivedDraftID(msg, normalized.MessageID); draftID != "" {
out["draft_id"] = draftID
}
if normalized.ReplyTo != "" {
out["reply_to"] = normalized.ReplyTo
}
if normalized.ReplyToSMTPMessageID != "" {
out["reply_to_smtp_message_id"] = normalized.ReplyToSMTPMessageID
}
out["date_formatted"] = normalized.DateFormatted
out["message_state_text"] = normalized.MessageStateText
if normalized.PriorityType != "" {
out["priority_type"] = normalized.PriorityType
out["priority_type_text"] = normalized.PriorityTypeText
}
out["body_plain_text"] = normalized.BodyPlainText
out["body_preview"] = normalized.BodyPreview
if html && normalized.BodyHTML != "" {
out["body_html"] = normalized.BodyHTML
}
if normalized.CalendarEvent != nil {
out["calendar_event"] = normalized.CalendarEvent
}
out["attachments"] = buildPublicAttachments(msg)
return out
}
// buildPublicAttachments returns the unified "attachments" list for
// message output, merging inline and regular attachments into a single
// shape with the IsInline flag set accordingly.
func buildPublicAttachments(msg map[string]interface{}) []mailPublicAttachmentOutput {
rawAtts, _ := msg["attachments"].([]interface{})
out := make([]mailPublicAttachmentOutput, 0, len(rawAtts))
for _, item := range rawAtts {
att, ok := item.(map[string]interface{})
if !ok {
continue
}
id := strVal(att["id"])
filename := strVal(att["filename"])
contentType := resolveAttachmentContentType(att, filename)
isInline, _ := att["is_inline"].(bool)
out = append(out, mailPublicAttachmentOutput{
ID: id,
Filename: filename,
ContentType: contentType,
AttachmentType: intVal(att["attachment_type"]),
IsInline: isInline,
CID: strVal(att["cid"]),
})
}
return out
}
// derivedDraftID returns the draft identifier for a message that is
// itself a draft (message_state == draft). For non-draft messages returns
// "". messageID is used as fallback when the backend omits draft_id.
func derivedDraftID(msg map[string]interface{}, messageID string) string {
if draftID := strVal(msg["draft_id"]); draftID != "" {
return draftID
}
if strings.EqualFold(strVal(msg["folder_id"]), "DRAFT") {
return messageID
}
return ""
}
// buildMessageForCompose assembles the internal normalized message structure used by compose flows.
// - base64url-decodes body fields
// - splits attachments into images (is_inline=true) and attachments (is_inline=false)
// - omits body_html when html=false
// - falls back body_plain_text → body_preview when empty
// - sanitizes body_plain_text for terminal output (strips ANSI escapes and bare CR)
func buildMessageForCompose(msg map[string]interface{}, urlMap map[string]string, html bool) normalizedMessageForCompose {
out := normalizedMessageForCompose{
MessageID: strVal(msg["message_id"]),
ThreadID: strVal(msg["thread_id"]),
SMTPMessageID: strVal(msg["smtp_message_id"]),
Subject: strVal(msg["subject"]),
From: toAddressObject(msg["head_from"]),
To: toAddressList(msg["to"]),
CC: toAddressList(msg["cc"]),
BCC: toAddressList(msg["bcc"]),
Date: strVal(msg["date"]),
InReplyTo: strVal(msg["in_reply_to"]),
References: toStringList(msg["references"]),
}
out.ReplyTo = strVal(msg["reply_to"])
out.ReplyToSMTPMessageID = strVal(msg["reply_to_smtp_message_id"])
// State
internalDate := strVal(msg["internal_date"])
out.InternalDate = internalDate
out.DateFormatted = common.FormatTime(internalDate)
state := intVal(msg["message_state"])
out.MessageState = state
out.MessageStateText = messageStateText(state)
out.FolderID = strVal(msg["folder_id"])
out.LabelIDs = toStringList(msg["label_ids"])
// Priority: prefer label_ids (HIGH_PRIORITY/LOW_PRIORITY), fall back to priority_type field.
priorityType := strVal(msg["priority_type"])
out.PriorityType = priorityType
if priorityType != "" {
out.PriorityTypeText = priorityTypeText(priorityType)
}
for _, label := range out.LabelIDs {
switch label {
case "HIGH_PRIORITY":
out.PriorityType = "1"
out.PriorityTypeText = "high"
case "LOW_PRIORITY":
out.PriorityType = "5"
out.PriorityTypeText = "low"
}
}
if securityLevel := toSecurityLevel(msg["security_level"]); securityLevel != nil {
out.SecurityLevel = securityLevel
}
// Body
plainText := decodeBase64URL(strVal(msg["body_plain_text"]))
preview := decodeBase64URL(strVal(msg["body_preview"]))
if plainText == "" {
plainText = preview
}
out.BodyPlainText = sanitizeForTerminal(plainText)
out.BodyPreview = preview
if html {
out.BodyHTML = decodeBase64URL(strVal(msg["body_html"]))
}
// Calendar event
if bodyCalendar := strVal(msg["body_calendar"]); bodyCalendar != "" {
if decoded := decodeBase64URL(bodyCalendar); decoded != "" {
if parsed := ics.ParseEvent(decoded); parsed != nil {
ce := &calendarEventOutput{
Method: parsed.Method,
UID: parsed.UID,
Summary: parsed.Summary,
Location: parsed.Location,
Organizer: parsed.Organizer,
Attendees: parsed.Attendees,
}
if !parsed.Start.IsZero() {
ce.Start = parsed.Start.UTC().Format(time.RFC3339)
}
if !parsed.End.IsZero() {
ce.End = parsed.End.UTC().Format(time.RFC3339)
}
out.CalendarEvent = ce
}
}
}
// Attachments
attachments := make([]mailAttachmentOutput, 0)
images := make([]mailImageOutput, 0)
if rawAtts, ok := msg["attachments"].([]interface{}); ok {
for _, item := range rawAtts {
att, ok := item.(map[string]interface{})
if !ok {
continue
}
id := strVal(att["id"])
filename := strVal(att["filename"])
attType := intVal(att["attachment_type"])
isInline, _ := att["is_inline"].(bool)
cid := strVal(att["cid"])
contentType := resolveAttachmentContentType(att, filename)
dlURL := urlMap[id]
if isInline && cid != "" {
images = append(images, mailImageOutput{
ID: id,
Filename: filename,
ContentType: contentType,
CID: cid,
DownloadURL: dlURL,
})
} else {
attachments = append(attachments, mailAttachmentOutput{
ID: id,
Filename: filename,
ContentType: contentType,
AttachmentType: attType,
DownloadURL: dlURL,
})
}
}
}
out.Attachments = attachments
out.Images = images
return out
}
// pickSafeMessageFields returns a shallow copy of msg containing only
// fields safe to expose in public output (per shouldExposeRawMessageField).
func pickSafeMessageFields(msg map[string]interface{}) map[string]interface{} {
out := make(map[string]interface{}, len(msg))
for key, value := range msg {
if !shouldExposeRawMessageField(key) {
continue
}
out[key] = value
}
return out
}
// shouldExposeRawMessageField reports whether key from a raw message
// response is safe to pass through to public output (i.e. not a body field
// handled separately and not in rawMessageExcludedFields).
func shouldExposeRawMessageField(key string) bool {
if strings.HasPrefix(key, "body_") {
return false
}
_, blocked := rawMessageExcludedFields[key]
return !blocked
}
// attachmentTypeSmall is the API value for a regular attachment: embedded in
// the EML at send time (base64, counted against the 25 MB single-message limit).
// attachmentTypeLarge is the API value for a large attachment that is already
// embedded as a download link inside the message body. These must not be
// downloaded and re-attached during forward: the link in the body is sufficient
// and downloading could cause OOM for very large files.
// Both values align with the IDL i32 enum on Attachment.attachment_type.
const (
attachmentTypeSmall = 1
attachmentTypeLarge = 2
)
// forwardSourceAttachment is the compose-side view of an attachment on the
// original message being forwarded. AttachmentType 1 means a normal
// attachment that will be downloaded and re-attached; type 2 (large) is
// represented as an in-body link instead.
type forwardSourceAttachment struct {
ID string
Filename string
ContentType string
AttachmentType int // 1=normal, 2=large (link in body, skip download)
DownloadURL string
}
// inlineSourcePart is the compose-side view of a CID-referenced inline
// resource on the original message that will be re-embedded in the
// reply / forward.
type inlineSourcePart struct {
ID string
Filename string
ContentType string
CID string
DownloadURL string
}
// composeSourceMessage bundles everything a reply / forward operation needs
// to know about the original message: the normalized originalMessage, the
// list of forward-able attachments, the list of inline parts to re-embed,
// and the set of attachment IDs whose download preflight failed.
type composeSourceMessage struct {
Original originalMessage
ForwardAttachments []forwardSourceAttachment
InlineImages []inlineSourcePart
FailedAttachmentIDs map[string]bool
OriginalCalendarICS []byte // raw ICS bytes from body_calendar (for forward passthrough)
}
// fetchComposeSourceMessage loads a message via the +message pipeline and converts it
// to compose-friendly data (quote metadata + forward attachments).
func fetchComposeSourceMessage(runtime *common.RuntimeContext, mailboxID, messageID string) (composeSourceMessage, error) {
msg, err := fetchFullMessage(runtime, mailboxID, messageID, true)
if err != nil {
return composeSourceMessage{}, err
}
var originalCalICS []byte
if bodyCalendar := strVal(msg["body_calendar"]); bodyCalendar != "" {
if decoded := decodeBase64URL(bodyCalendar); decoded != "" {
originalCalICS = []byte(decoded)
}
}
attIDs := extractAttachmentIDs(msg)
urlMap, warnings := fetchAttachmentURLs(runtime, mailboxID, messageID, attIDs)
failedIDs := make(map[string]bool)
for _, w := range warnings {
if w.Code == "attachment_download_url_failed_id" && w.AttachmentID != "" {
failedIDs[w.AttachmentID] = true
}
}
out := buildMessageForCompose(msg, urlMap, true)
orig := toOriginalMessageForCompose(out)
return composeSourceMessage{
Original: orig,
ForwardAttachments: toForwardSourceAttachments(out),
InlineImages: toInlineSourceParts(out),
FailedAttachmentIDs: failedIDs,
OriginalCalendarICS: originalCalICS,
}, nil
}
// validateForwardAttachmentURLs checks that all forwarded attachments (non-inline)
// have valid download URLs. Inline images are checked separately by validateInlineImageURLs.
func validateForwardAttachmentURLs(src composeSourceMessage) error {
var missing []string
for _, att := range src.ForwardAttachments {
if att.AttachmentType == attachmentTypeLarge {
continue
}
if src.FailedAttachmentIDs[att.ID] {
continue
}
if att.DownloadURL == "" {
missing = append(missing, fmt.Sprintf("attachment %q (%s)", att.Filename, att.ID))
}
}
if len(missing) > 0 {
return mailInvalidResponseError("failed to fetch download URLs for: %s", strings.Join(missing, ", "))
}
return nil
}
// validateInlineImageURLs checks only inline images have valid download URLs.
// Use for HTML reply/reply-all where inline images are embedded in the quoted body.
func validateInlineImageURLs(src composeSourceMessage) error {
var missing []string
for _, img := range src.InlineImages {
if img.DownloadURL == "" {
missing = append(missing, fmt.Sprintf("inline image %q (%s)", img.Filename, img.ID))
}
}
if len(missing) > 0 {
return mailInvalidResponseError("failed to fetch download URLs for: %s", strings.Join(missing, ", "))
}
return nil
}
// toOriginalMessageForCompose lifts the normalized message representation
// into the originalMessage value type used by +reply / +forward body
// builders.
func toOriginalMessageForCompose(out normalizedMessageForCompose) originalMessage {
fromEmail, fromName := out.From.Email, out.From.Name
toList := toAddressEmailList(out.To)
ccList := toAddressEmailList(out.CC)
toFullList := toAddressPairList(out.To)
ccFullList := toAddressPairList(out.CC)
headTo := ""
if len(toList) > 0 {
headTo = toList[0]
}
headDate := ""
if internalDate := out.InternalDate; internalDate != "" {
if ms, err := strconv.ParseInt(internalDate, 10, 64); err == nil {
headDate = formatMailDate(ms, detectSubjectLang(out.Subject))
}
}
bodyHTML := out.BodyHTML
bodyText := out.BodyPlainText
bodyRaw := bodyHTML
if bodyRaw == "" {
bodyRaw = bodyText
}
references := ""
if len(out.References) > 0 {
references = strings.Join(out.References, " ")
}
// Strip CR and LF from the inherited subject to prevent header injection when
// this value is later passed to emlbuilder.Subject() in reply/forward flows.
// A malicious source email could carry "\r\nBcc: evil@evil.com" in its Subject.
safeSubject := strings.NewReplacer("\r", "", "\n", "").Replace(out.Subject)
return originalMessage{
subject: safeSubject,
headFrom: fromEmail,
headFromName: fromName,
headTo: headTo,
replyTo: out.ReplyTo,
replyToSMTPMessageID: out.ReplyToSMTPMessageID,
smtpMessageId: out.SMTPMessageID,
threadId: out.ThreadID,
bodyRaw: bodyRaw,
headDate: headDate,
references: references,
toAddresses: toList,
ccAddresses: ccList,
toAddressesFull: toFullList,
ccAddressesFull: ccFullList,
}
}
// toForwardSourceAttachments extracts the forward-capable attachments from
// a normalized message (non-inline attachments, both regular and large).
func toForwardSourceAttachments(out normalizedMessageForCompose) []forwardSourceAttachment {
atts := make([]forwardSourceAttachment, 0, len(out.Attachments))
for _, att := range out.Attachments {
atts = append(atts, forwardSourceAttachment{
ID: att.ID,
Filename: att.Filename,
ContentType: att.ContentType,
AttachmentType: att.AttachmentType,
DownloadURL: att.DownloadURL,
})
}
return atts
}
// toInlineSourceParts extracts the CID-referenced inline resources from a
// normalized message for re-embedding in a reply / forward.
func toInlineSourceParts(out normalizedMessageForCompose) []inlineSourcePart {
parts := make([]inlineSourcePart, 0, len(out.Images))
for _, img := range out.Images {
if img.CID == "" {
continue
}
parts = append(parts, inlineSourcePart{
ID: img.ID,
Filename: img.Filename,
ContentType: img.ContentType,
CID: img.CID,
DownloadURL: img.DownloadURL,
})
}
return parts
}
// downloadAttachmentContent fetches the content at downloadURL.
// Lark pre-signed download URLs embed an authcode in the query string and do
// not require an Authorization header, so we never send the Bearer token.
func downloadAttachmentContent(runtime *common.RuntimeContext, downloadURL string) ([]byte, error) {
u, err := url.Parse(downloadURL)
if err != nil {
return nil, mailInvalidResponseError("invalid attachment download URL: %v", err).WithCause(err)
}
if u.Scheme != "https" {
return nil, mailInvalidResponseError("attachment download URL must use https (got %q)", u.Scheme)
}
if u.Host == "" {
return nil, mailInvalidResponseError("attachment download URL has no host")
}
httpClient, err := runtime.Factory.HttpClient()
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "failed to get HTTP client: %v", err).WithCause(err)
}
req, err := http.NewRequestWithContext(runtime.Ctx(), http.MethodGet, downloadURL, nil)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "failed to build attachment download request: %v", err).WithCause(err)
}
// Do NOT send Authorization: the download_url is a pre-signed URL with an
// authcode embedded in the query string. Attaching the Bearer token would
// leak it to whatever host the URL points at (SSRF / token exfiltration).
resp, err := httpClient.Do(req)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to download attachment: %v", err).WithCause(err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
if resp.StatusCode >= 500 {
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "failed to download attachment: HTTP %d", resp.StatusCode).
WithCode(resp.StatusCode).
WithRetryable()
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return nil, errs.NewAPIError(subtype, "failed to download attachment: HTTP %d", resp.StatusCode).WithCode(resp.StatusCode)
}
limitedReader := io.LimitReader(resp.Body, int64(MaxAttachmentDownloadBytes)+1)
data, err := io.ReadAll(limitedReader)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to read attachment content: %v", err).WithCause(err)
}
if len(data) > MaxAttachmentDownloadBytes {
return nil, mailFailedPreconditionError("attachment download exceeds %d MB size limit", MaxAttachmentDownloadBytes/1024/1024).
WithHint("download or forward this large attachment outside the inline/small-attachment path")
}
return data, nil
}
// --- internal helpers ---
// strVal returns v as a string when it is one, otherwise "". Used to
// safely extract string fields from decoded JSON maps.
func strVal(v interface{}) string {
s, _ := v.(string)
return s
}
// intVal returns v as an int, parsing string forms and coercing JSON
// float64 when needed. Returns 0 when v is nil or non-numeric.
func intVal(v interface{}) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case json.Number:
i, _ := n.Int64()
return int(i)
}
return 0
}
// decodeBase64URL returns the decoded bytes of a base64url-encoded string
// (either padded or raw). Returns "" on decode error.
func decodeBase64URL(s string) string {
if s == "" {
return ""
}
b, err := base64.URLEncoding.DecodeString(s)
if err != nil {
b, err = base64.RawURLEncoding.DecodeString(s)
if err != nil {
return s
}
}
return string(b)
}
// decodeBodyFields decodes body_html and body_plain_text from src into dst.
// Fields absent or empty in src are skipped. Both padding and no-padding base64url variants
// are accepted by the underlying decodeBase64URL call.
func decodeBodyFields(src, dst map[string]interface{}) {
for _, field := range []string{"body_html", "body_plain_text"} {
if s := strVal(src[field]); s != "" {
dst[field] = decodeBase64URL(s)
}
}
}
// ansiEscapeRe matches ANSI CSI escape sequences (ESC '[' ... <final byte>).
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
// sanitizeForTerminal strips ANSI escape sequences, bare CR characters, and
// dangerous Unicode code points (BiDi overrides, zero-width chars, etc.) to
// prevent terminal injection from untrusted email content. LF is preserved
// because legitimate multi-line content (body_text, body_html_summary) is
// printed through this helper; use sanitizeForSingleLine when the caller
// needs a single-line guarantee.
func sanitizeForTerminal(s string) string {
s = ansiEscapeRe.ReplaceAllString(s, "")
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if r == '\r' {
continue
}
if common.IsDangerousUnicode(r) {
continue
}
b.WriteRune(r)
}
return b.String()
}
// sanitizeForSingleLine is sanitizeForTerminal plus LF removal, for callers
// whose output must stay on one logical line — stderr hints, embedded
// command-line arguments, etc. A malicious From header or subject containing
// "\ntip: ..." can no longer forge extra lines in the prompt and trick a
// reader into thinking the CLI emitted them.
func sanitizeForSingleLine(s string) string {
return strings.ReplaceAll(sanitizeForTerminal(s), "\n", "")
}
// toAddressObject converts a raw address field (map form) from the API
// response into mailAddressOutput. Returns zero value when v isn't a map.
func toAddressObject(v interface{}) mailAddressOutput {
if m, ok := v.(map[string]interface{}); ok {
return mailAddressOutput{Email: strVal(m["mail_address"]), Name: strVal(m["name"])}
}
return mailAddressOutput{}
}
// toAddressList converts a raw address-list field from the API response
// (array of maps) into []mailAddressOutput.
func toAddressList(v interface{}) []mailAddressOutput {
list, _ := v.([]interface{})
out := make([]mailAddressOutput, 0, len(list))
for _, item := range list {
out = append(out, toAddressObject(item))
}
return out
}
// toAddressEmailList extracts just the email addresses from a list of
// mailAddressOutput, dropping entries with empty email.
func toAddressEmailList(raw []mailAddressOutput) []string {
out := make([]string, 0, len(raw))
for _, addr := range raw {
email := addr.Email
if email != "" {
out = append(out, email)
}
}
return out
}
// toStringList coerces a JSON array of strings / anything-stringifiable
// into []string. Returns nil when v is not an array.
func toStringList(v interface{}) []string {
list, _ := v.([]interface{})
out := make([]string, 0, len(list))
for _, item := range list {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
// toSecurityLevel extracts the risk-banner info from a raw message's
// security_level field. Returns nil when absent / not flagged.
func toSecurityLevel(v interface{}) *mailSecurityLevelOutput {
raw, ok := v.(map[string]interface{})
if !ok || raw == nil {
return nil
}
riskBannerLevel := strVal(raw["risk_banner_level"])
riskBannerReason := strVal(raw["risk_banner_reason"])
spamBannerType := strVal(raw["spam_banner_type"])
return &mailSecurityLevelOutput{
IsRisk: boolVal(raw["is_risk"]),
RiskBannerLevel: riskBannerLevel,
RiskBannerReason: riskBannerReason,
IsHeaderFromExternal: boolVal(raw["is_header_from_external"]),
ViaDomain: strVal(raw["via_domain"]),
SpamBannerType: spamBannerType,
SpamUserRuleID: strVal(raw["spam_user_rule_id"]),
SpamBannerInfo: strVal(raw["spam_banner_info"]),
}
}
// boolVal returns v as a bool when it is one, otherwise false.
func boolVal(v interface{}) bool {
b, _ := v.(bool)
return b
}
// firstNonEmpty returns the first non-empty value in values, or "" when
// all values are empty.
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
// resolveAttachmentContentType returns the MIME type of an attachment,
// falling back to the extension-based guess when the API response doesn't
// include one.
func resolveAttachmentContentType(att map[string]interface{}, filename string) string {
if ct := strVal(att["content_type"]); ct != "" {
return ct
}
if ext := strings.ToLower(filepath.Ext(filename)); ext != "" {
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
}
return "application/octet-stream"
}
// messageStateText maps the numeric message_state code (1/2/3) to the
// human-readable label received / sent / draft. Unknown values become
// "unknown".
func messageStateText(state int) string {
switch state {
case 1:
return "received"
case 2:
return "sent"
case 3:
return "draft"
default:
return "unknown"
}
}
// priorityTypeText maps the server priority enum ("HIGH" / "LOW" /
// "NORMAL" / empty) to the CLI-facing label shown in message output.
func priorityTypeText(priorityType string) string {
switch priorityType {
case "0":
return "unknown"
case "1":
return "high"
case "3":
return "normal"
case "5":
return "low"
default:
return "unknown"
}
}
// priorityFlag is the common flag definition for --priority, shared by all compose shortcuts.
var priorityFlag = common.Flag{
Name: "priority",
Desc: "Email priority: high, normal, low. If omitted, no priority header is set.",
}
// parsePriority parses the --priority flag value and returns the X-Cli-Priority
// header value. Returns "" if the priority should not be set (empty or "normal").
func parsePriority(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "":
return "", nil
case "high":
return "1", nil
case "normal":
return "", nil
case "low":
return "5", nil
default:
return "", mailValidationParamError("--priority", "invalid --priority value %q: expected high, normal, or low", value)
}
}
// validatePriorityFlag validates the --priority flag value in Validate, so invalid
// values are caught before Execute (and before dry-run prints an API plan).
func validatePriorityFlag(runtime *common.RuntimeContext) error {
v := runtime.Str("priority")
if v == "" {
return nil
}
_, err := parsePriority(v)
return err
}
// applyPriority sets the X-Cli-Priority header on the EML builder if priority is non-empty.
func applyPriority(bld emlbuilder.Builder, priority string) emlbuilder.Builder {
if priority == "" {
return bld
}
return bld.Header("X-Cli-Priority", priority)
}
// parseNetAddrs converts a comma-separated address string to []net/mail.Address.
// It reuses ParseMailboxList for display-name-aware parsing and deduplicates
// by email address (case-insensitive), preserving the first occurrence.
func parseNetAddrs(raw string) []netmail.Address {
boxes := ParseMailboxList(raw)
seen := make(map[string]bool, len(boxes))
out := make([]netmail.Address, 0, len(boxes))
for _, m := range boxes {
key := strings.ToLower(m.Email)
if seen[key] {
continue
}
seen[key] = true
out = append(out, netmail.Address{Name: m.Name, Address: m.Email})
}
return out
}
// mergeAddrLists merges two comma-separated address lists, deduplicating by
// email (case-insensitive). Addresses in base come first; addresses in extra
// that already appear in base are silently dropped.
func mergeAddrLists(base, extra string) string {
if extra == "" {
return base
}
if base == "" {
return extra
}
seen := make(map[string]bool)
for _, m := range ParseMailboxList(base) {
seen[strings.ToLower(m.Email)] = true
}
var additions []string
for _, m := range ParseMailboxList(extra) {
lower := strings.ToLower(m.Email)
if seen[lower] {
continue
}
seen[lower] = true
additions = append(additions, m.String())
}
if len(additions) == 0 {
return base
}
return base + ", " + strings.Join(additions, ", ")
}
// ---- Compose domain types --------------------------------------------------
// originalMessage holds the metadata and body extracted from the original email.
type originalMessage struct {
subject string
headFrom string
headFromName string // display name of sender, for attribution line
headTo string // first recipient (likely current user's email)
replyTo string // Reply-To address; reply/reply-all should prefer this over headFrom
replyToSMTPMessageID string // SMTP Message-ID of the Reply-To target
smtpMessageId string
threadId string
bodyRaw string // raw body from API (may be HTML)
headDate string // Date header, for attribution line
references string // space-separated RFC 2822 References chain from original
toAddresses []string // email-only list, used by reply-all recipient logic
ccAddresses []string // email-only list, used by reply-all recipient logic
toAddressesFull []mailAddressPair // name+email pairs for quote display
ccAddressesFull []mailAddressPair // name+email pairs for quote display
}
// normalizeMessageID strips angle brackets and whitespace from an RFC 5322
// Message-ID so it can be used as a bare value in In-Reply-To / References
// headers (emlbuilder re-wraps in angle brackets itself).
func normalizeMessageID(id string) string {
trimmed := strings.TrimSpace(id)
trimmed = strings.TrimPrefix(trimmed, "<")
trimmed = strings.TrimSuffix(trimmed, ">")
return strings.TrimSpace(trimmed)
}
// buildDraftSendOutput formats a successful drafts.send response into the
// public output map (message_id / thread_id plus an optional recall tip
// when the backend reports the message is within the recall window).
func buildDraftSendOutput(resData map[string]interface{}, mailboxID string) map[string]interface{} {
out := map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}
if recallStatus, ok := resData["recall_status"].(string); ok && recallStatus == "available" {
messageID, _ := resData["message_id"].(string)
out["recall_available"] = true
out["recall_tip"] = fmt.Sprintf(
`This message can be recalled within 24 hours. To recall: lark-cli mail user_mailbox.sent_messages recall --params '{"user_mailbox_id":"%s","message_id":"%s"}'`,
mailboxID, messageID)
}
if automationDisable, ok := resData["automation_send_disable"]; ok {
if automation, ok := automationDisable.(map[string]interface{}); ok {
if reason, ok := automation["reason"].(string); ok && strings.TrimSpace(reason) != "" {
out["automation_send_disable_reason"] = strings.TrimSpace(reason)
}
if reference, ok := automation["reference"].(string); ok && strings.TrimSpace(reference) != "" {
out["automation_send_disable_reference"] = strings.TrimSpace(reference)
}
}
}
return out
}
// buildDraftSavedOutput formats a successful drafts.create / drafts.update
// response into the public output map (draft_id + optional preview URL).
func buildDraftSavedOutput(draftResult draftpkg.DraftResult, mailboxID string) map[string]interface{} {
out := map[string]interface{}{
"draft_id": draftResult.DraftID,
"tip": fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftResult.DraftID),
}
if draftResult.Reference != "" {
out["reference"] = draftResult.Reference
}
return out
}
// normalizeInlineCID strips angle brackets from a Content-ID so it can be
// referenced in <img src="cid:..."> and emlbuilder.AddFileInline
// consistently (both expect the bare CID).
func normalizeInlineCID(cid string) string {
trimmed := strings.TrimSpace(cid)
if len(trimmed) >= 4 && strings.EqualFold(trimmed[:4], "cid:") {
trimmed = trimmed[4:]
}
trimmed = strings.TrimPrefix(trimmed, "<")
trimmed = strings.TrimSuffix(trimmed, ">")
return strings.TrimSpace(trimmed)
}
// validateInlineCIDs checks bidirectional CID consistency between HTML body and
// inline MIME parts — the same checks as postProcessInlineImages in draft-edit.
// 1. Every cid: reference in HTML must have a corresponding inline part (checked
// against userCIDs + extraCIDs combined).
// 2. Every user-provided inline part must be referenced in HTML (orphan check
// against userCIDs only — extraCIDs such as source-message images in
// reply/forward are excluded because quoting may drop some references).
func validateInlineCIDs(html string, userCIDs, extraCIDs []string) error {
allCIDs := append(append([]string{}, userCIDs...), extraCIDs...)
if err := draftpkg.ValidateCIDReferences(html, allCIDs); err != nil {
return err
}
if len(userCIDs) > 0 {
orphaned := draftpkg.FindOrphanedCIDs(html, userCIDs)
if len(orphaned) > 0 {
return mailValidationParamError("--inline", "inline images with cids %v are not referenced by any <img src=\"cid:...\"> in the HTML body and will appear as unexpected attachments; remove unused --inline entries or add matching <img> tags", orphaned)
}
}
return nil
}
// addInlineImagesToBuilder downloads each inline image referenced in images
// and attaches it to bld with the caller-supplied CID preserved. Returns the
// extended builder, the list of CIDs that were actually attached (empty CIDs
// are skipped), and the total bytes of downloaded inline content (for
// attachment-size budgeting upstream). Errors propagate immediately; callers
// should not reuse the builder on error since partial state may have been
// committed.
func addInlineImagesToBuilder(runtime *common.RuntimeContext, bld emlbuilder.Builder, images []inlineSourcePart) (emlbuilder.Builder, []string, int64, error) {
var cids []string
var totalBytes int64
for _, img := range images {
content, err := downloadAttachmentContent(runtime, img.DownloadURL)
if err != nil {
return bld, nil, 0, mailDecorateProblemMessage(err, "failed to download inline resource %s", img.Filename)
}
cid := normalizeInlineCID(img.CID)
if cid == "" {
continue
}
contentType := img.ContentType
if contentType == "" {
contentType = "application/octet-stream"
}
bld = bld.AddInline(content, contentType, img.Filename, cid)
cids = append(cids, cid)
totalBytes += int64(len(content))
}
return bld, cids, totalBytes, nil
}
// 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 InlineSpec struct {
CID string `json:"cid"`
FilePath string `json:"file_path"`
}
// parseInlineSpecs parses the --inline flag value as a JSON array of InlineSpec.
// Returns an empty slice when raw is empty.
func parseInlineSpecs(raw string) ([]InlineSpec, error) {
if strings.TrimSpace(raw) == "" {
return nil, nil
}
var specs []InlineSpec
if err := json.Unmarshal([]byte(raw), &specs); err != nil {
return nil, mailValidationParamError("--inline", "--inline must be a JSON array, e.g. '[{\"cid\":\"a1b2c3d4e5f6a7b8c9d0\",\"file_path\":\"./banner.png\"}]': %v", err).WithCause(err)
}
for i, s := range specs {
if strings.TrimSpace(s.CID) == "" {
return nil, mailValidationParamError("--inline", "--inline entry %d: \"cid\" must not be empty", i)
}
if strings.TrimSpace(s.FilePath) == "" {
return nil, mailValidationParamError("--inline", "--inline entry %d: \"file_path\" must not be empty", i)
}
}
return specs, nil
}
// inlineSpecFilePaths returns the file paths from a slice of InlineSpec, for use in size checks.
func inlineSpecFilePaths(specs []InlineSpec) []string {
if len(specs) == 0 {
return nil
}
paths := make([]string, len(specs))
for i, s := range specs {
paths[i] = s.FilePath
}
return paths
}
// validateEventSendTimeExclusion checks that --send-time and --event-* are not
// used together. This is enforced here (in Validate, before Execute) because the
// Shortcut framework does not expose a cobra-level hook for MarkFlagsMutuallyExclusive.
func validateEventSendTimeExclusion(runtime *common.RuntimeContext) error {
if runtime.Str("send-time") == "" {
return nil
}
for _, f := range []string{"event-summary", "event-start", "event-end", "event-location"} {
if runtime.Str(f) != "" {
return mailValidationError("--send-time and --event-* are mutually exclusive: a calendar invitation must be sent immediately so recipients can respond before the event").
WithParams(
mailInvalidParam("--send-time", "mutually exclusive with --event-*"),
mailInvalidParam("--event-*", "mutually exclusive with --send-time"),
)
}
}
return nil
}
// validateSendTime checks that --send-time, if provided, requires --confirm-send,
// is a valid Unix timestamp in seconds, and is at least 5 minutes in the future.
func validateSendTime(runtime *common.RuntimeContext) error {
sendTime := runtime.Str("send-time")
if sendTime == "" {
return nil
}
if !runtime.Bool("confirm-send") {
return mailValidationParamError("--send-time", "--send-time requires --confirm-send to be set")
}
ts, err := strconv.ParseInt(sendTime, 10, 64)
if err != nil {
return mailValidationParamError("--send-time", "--send-time must be a valid Unix timestamp in seconds, got %q", sendTime).WithCause(err)
}
minTime := time.Now().Unix() + 5*60
if ts < minTime {
return mailValidationParamError("--send-time", "--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts)
}
return nil
}
// validateConfirmSendScope checks that the user's token includes the
// mail:user_mailbox.message:send scope when --confirm-send is set.
// This scope is not declared in the shortcut's static Scopes (to keep the
// default draft-only path accessible without the sensitive send permission),
// so we validate it dynamically here.
func validateConfirmSendScope(runtime *common.RuntimeContext) error {
if !runtime.Bool("confirm-send") {
return nil
}
appID := runtime.Config.AppID
userOpenId := runtime.UserOpenId()
if appID == "" || userOpenId == "" {
return nil
}
stored := auth.GetStoredToken(appID, userOpenId)
if stored == nil {
return nil
}
required := []string{"mail:user_mailbox.message:send"}
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
return errs.NewPermissionError(errs.SubtypeMissingScope,
"--confirm-send requires scope: %s", strings.Join(missing, ", ")).
WithHint("run `lark-cli auth login --scope %q` to grant the send permission", strings.Join(missing, " ")).
WithMissingScopes(missing...).
WithIdentity("user")
}
return nil
}
// validateFolderReadScope checks that the user's token includes the
// mail:user_mailbox.folder:read scope. Called on-demand by listMailboxFolders
// before hitting the folders API. System folders are resolved locally and
// never reach this check.
func validateFolderReadScope(runtime *common.RuntimeContext) error {
appID := runtime.Config.AppID
userOpenId := runtime.UserOpenId()
if appID == "" || userOpenId == "" {
return nil
}
stored := auth.GetStoredToken(appID, userOpenId)
if stored == nil {
return nil
}
required := []string{"mail:user_mailbox.folder:read"}
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
return errs.NewPermissionError(errs.SubtypeMissingScope,
"folder resolution requires scope: %s", strings.Join(missing, ", ")).
WithHint("run `lark-cli auth login --scope %q` to grant folder read permission", strings.Join(missing, " ")).
WithMissingScopes(missing...).
WithIdentity("user")
}
return nil
}
// validateLabelReadScope checks that the user's token includes the
// mail:user_mailbox.message:modify scope. Called on-demand by listMailboxLabels
// before hitting the labels API. System labels are resolved locally and
// never reach this check.
func validateLabelReadScope(runtime *common.RuntimeContext) error {
appID := runtime.Config.AppID
userOpenId := runtime.UserOpenId()
if appID == "" || userOpenId == "" {
return nil
}
stored := auth.GetStoredToken(appID, userOpenId)
if stored == nil {
return nil
}
required := []string{"mail:user_mailbox.message:modify"}
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
return errs.NewPermissionError(errs.SubtypeMissingScope,
"label resolution requires scope: %s", strings.Join(missing, ", ")).
WithHint("run `lark-cli auth login --scope %q` to grant label access permission", strings.Join(missing, " ")).
WithMissingScopes(missing...).
WithIdentity("user")
}
return nil
}
// validateComposeHasAtLeastOneRecipient ensures a compose-style invocation
// has at least one recipient field populated. Returns ErrValidation when
// all three (to/cc/bcc) are empty or whitespace-only.
func validateComposeHasAtLeastOneRecipient(to, cc, bcc string) error {
if strings.TrimSpace(to) == "" && strings.TrimSpace(cc) == "" && strings.TrimSpace(bcc) == "" {
return mailValidationError("at least one recipient (--to, --cc, or --bcc) is required").
WithParams(
mailInvalidParam("--to", "at least one recipient is required"),
mailInvalidParam("--cc", "at least one recipient is required"),
mailInvalidParam("--bcc", "at least one recipient is required"),
)
}
return validateRecipientCount(to, cc, bcc)
}
// validateRecipientCount checks that the total number of recipients across
// To, CC, and BCC does not exceed MaxRecipientCount.
func validateRecipientCount(to, cc, bcc string) error {
count := len(ParseMailboxList(to)) + len(ParseMailboxList(cc)) + len(ParseMailboxList(bcc))
if count > MaxRecipientCount {
return mailValidationError("total recipient count %d exceeds the limit of %d (To + CC + BCC combined)", count, MaxRecipientCount).
WithParams(
mailInvalidParam("--to", "recipient count contributes to combined limit"),
mailInvalidParam("--cc", "recipient count contributes to combined limit"),
mailInvalidParam("--bcc", "recipient count contributes to combined limit"),
)
}
return nil
}
// validateComposeInlineAndAttachments validates the --attach / --inline
// flag pair before sending: it rejects --inline with --plain-text or with
// a non-HTML body, and checks that every --attach path passes filename /
// extension / size rules via the shared filecheck rules.
func validateComposeInlineAndAttachments(fio fileio.FileIO, attachFlag, inlineFlag string, plainText bool, body string) error {
if strings.TrimSpace(inlineFlag) != "" {
if plainText {
return mailValidationError("--inline is not supported with --plain-text (inline images require HTML body)").
WithParams(
mailInvalidParam("--inline", "requires HTML body"),
mailInvalidParam("--plain-text", "mutually exclusive with --inline"),
)
}
if body != "" && !bodyIsHTML(body) {
return mailValidationParamError("--inline", "--inline requires an HTML body (the provided body appears to be plain text; add HTML tags or remove --inline)")
}
}
inlineSpecs, err := parseInlineSpecs(inlineFlag)
if err != nil {
return err
}
// Preflight: verify explicit file paths exist and pass blocked-extension
// checks so that --dry-run surfaces local errors before Execute.
allPaths := append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...)
if _, err := statAttachmentFiles(fio, allPaths); err != nil {
return err
}
return nil
}
// buildCalendarBodyFromArgs builds ICS from explicit string arguments (for draft-edit).
// Callers are expected to have pre-validated startStr/endStr via parseEventTimeRange;
// parse errors are silently ignored here and produce a zero-time DTSTART/DTEND.
func buildCalendarBodyFromArgs(summary, startStr, endStr, location, senderEmail, toAddrs, ccAddrs string) []byte {
if summary == "" {
return nil
}
start, _ := parseISO8601(startStr)
end, _ := parseISO8601(endStr)
var attendees []ics.Address
for _, addr := range parseNetAddrs(toAddrs) {
if addr.Address != "" {
attendees = append(attendees, ics.Address{Name: addr.Name, Email: addr.Address})
}
}
for _, addr := range parseNetAddrs(ccAddrs) {
if addr.Address != "" {
attendees = append(attendees, ics.Address{Name: addr.Name, Email: addr.Address})
}
}
return ics.Build(ics.Event{
Summary: summary,
Location: location,
Start: start,
End: end,
Organizer: ics.Address{Email: senderEmail},
Attendees: attendees,
})
}
// joinAddresses joins draft Address list into comma-separated string.
func joinAddresses(addrs []draftpkg.Address) string {
if len(addrs) == 0 {
return ""
}
parts := make([]string, len(addrs))
for i, a := range addrs {
parts[i] = a.Address
}
return strings.Join(parts, ",")
}
// Calendar event flag definitions, shared by all compose shortcuts.
// Declared as individual vars (like priorityFlag and signatureFlag) so
// callers can list them explicitly in their Flags slice without relying
// on slice-index access.
var (
eventSummaryFlag = common.Flag{Name: "event-summary", Desc: "Calendar event title. Setting this enables calendar invitation mode."}
eventStartFlag = common.Flag{Name: "event-start", Desc: "Event start time (ISO 8601, e.g. 2026-04-20T14:00+08:00). Required when --event-summary is set."}
eventEndFlag = common.Flag{Name: "event-end", Desc: "Event end time (ISO 8601). Required when --event-summary is set."}
eventLocationFlag = common.Flag{Name: "event-location", Desc: "Event location (optional)."}
)
// validateEventFlags checks that --event-summary, --event-start, --event-end are either all set or all empty.
func validateEventFlags(runtime *common.RuntimeContext) error {
summary := runtime.Str("event-summary")
start := runtime.Str("event-start")
end := runtime.Str("event-end")
location := runtime.Str("event-location")
hasAny := summary != "" || start != "" || end != "" || location != ""
hasAll := summary != "" && start != "" && end != ""
if hasAny && !hasAll {
return mailValidationError("--event-summary, --event-start, and --event-end must all be provided together").
WithParams(
mailInvalidParam("--event-summary", "required with --event-start/--event-end"),
mailInvalidParam("--event-start", "required with --event-summary/--event-end"),
mailInvalidParam("--event-end", "required with --event-summary/--event-start"),
)
}
if summary == "" {
return nil
}
if _, _, err := parseEventTimeRange(start, end); err != nil {
return prefixEventRangeError("--event-", err)
}
return nil
}
// parseEventTimeRange parses start/end ISO 8601 strings and verifies that
// end is strictly after start. Shared by validateEventFlags (compose path)
// and buildDraftEditPatch (draft-edit path) so the rules stay in one place.
func parseEventTimeRange(start, end string) (time.Time, time.Time, error) {
startT, err := parseISO8601(start)
if err != nil {
return time.Time{}, time.Time{}, mailValidationError("start: invalid ISO 8601 time %q", start).WithCause(err)
}
endT, err := parseISO8601(end)
if err != nil {
return time.Time{}, time.Time{}, mailValidationError("end: invalid ISO 8601 time %q", end).WithCause(err)
}
if !endT.After(startT) {
return time.Time{}, time.Time{}, mailValidationError("end time must be after start time")
}
return startT, endT, nil
}
// prefixEventRangeError rewrites parseEventTimeRange's "start:" / "end:"
// error with the caller's flag-name prefix so users see the exact flag
// that caused the failure.
func prefixEventRangeError(flagPrefix string, err error) error {
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
var validationErr *errs.ValidationError
msg := p.Message
switch {
case strings.HasPrefix(msg, "start: "):
p.Message = fmt.Sprintf("%sstart: %s", flagPrefix, strings.TrimPrefix(msg, "start: "))
p.Subtype = errs.SubtypeInvalidArgument
if strings.HasPrefix(flagPrefix, "--") && errors.As(err, &validationErr) {
validationErr.Param = flagPrefix + "start"
}
return err
case strings.HasPrefix(msg, "end: "):
p.Message = fmt.Sprintf("%send: %s", flagPrefix, strings.TrimPrefix(msg, "end: "))
p.Subtype = errs.SubtypeInvalidArgument
if strings.HasPrefix(flagPrefix, "--") && errors.As(err, &validationErr) {
validationErr.Param = flagPrefix + "end"
}
return err
default:
return err
}
}
// parseISO8601 parses common ISO 8601 time formats.
func parseISO8601(s string) (time.Time, error) {
formats := []string{
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
"2006-01-02T15:04Z07:00",
"2006-01-02T15:04:05",
"2006-01-02T15:04",
"2006-01-02",
}
for _, f := range formats {
if t, err := time.Parse(f, s); err == nil {
return t, nil
}
}
return time.Time{}, mailValidationError("cannot parse %q as ISO 8601", s)
}
// buildCalendarBody generates an ICS VCALENDAR from compose flags and returns the bytes.
// Returns nil if --event-summary is not set.
func buildCalendarBody(runtime *common.RuntimeContext, senderEmail string, toAddrs, ccAddrs string) []byte {
return buildCalendarBodyFromArgs(
runtime.Str("event-summary"),
runtime.Str("event-start"),
runtime.Str("event-end"),
runtime.Str("event-location"),
senderEmail, toAddrs, ccAddrs,
)
}
// validateBotMailboxNotMe rejects the combination of bot identity with --mailbox me.
// bot uses tenant access token; "me" cannot be resolved to a user mailbox under TAT.
func validateBotMailboxNotMe(runtime *common.RuntimeContext) error {
if runtime.IsBot() && runtime.Str("mailbox") == "me" {
return mailValidationParamError("--mailbox",
"--as bot does not support --mailbox me: bot identity uses a tenant token and cannot resolve \"me\" to a user mailbox; "+
"pass an explicit email address, e.g. --mailbox alice@example.com")
}
return nil
}
// validateMessageIDs parses and validates the existing +messages comma-separated
// flag format. Unlike splitByComma, it keeps empty entries so "id1,,id2" fails
// locally. It intentionally does not enforce the server-side single-call limit:
// fetchFullMessages chunks backend requests into batches of 20.
func validateMessageIDs(raw string) ([]string, error) {
if strings.TrimSpace(raw) == "" {
return nil, mailValidationParamError("--message-ids", "--message-ids is required; provide one or more message IDs separated by commas")
}
parts := strings.Split(raw, ",")
ids := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for i, part := range parts {
id := strings.TrimSpace(part)
if id == "" {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
}
if part != id {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
}
if err := validateBatchGetMessageID(id, i); err != nil {
return nil, err
}
if _, ok := seen[id]; ok {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): duplicate message ID is not allowed", i+1, id)
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids, nil
}
func validateBatchGetMessageID(id string, index int) error {
if strings.Trim(id, "0123456789") == "" {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): numeric primary IDs are not supported by mail +messages; pass the Open API message_id from mail output", index+1, id)
}
decoded, rawErr := base64.RawURLEncoding.DecodeString(id)
if rawErr != nil {
decoded, rawErr = base64.URLEncoding.DecodeString(id)
}
if rawErr != nil || len(decoded) == 0 {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): expected a base64url Open API mail message_id from mail output", index+1, id)
}
return nil
}